v0.2.0 41cf2ec

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	"connectrpc.com/connect"
13	"github.com/a-h/templ"
14
15	pb "bnema.dev/ermite-api/gen/ermite"
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}
49
50// DiffView is how a page lays its diff out: the layouts in the page and the
51// way to switch between them. The commit page and the merge request pages
52// share it.
53type DiffView struct {
54	// View is the layout shown: "unified" or "split". Pinned is true when the
55	// URL chose it, which then wins over the choice remembered by the browser.
56	View   string
57	Pinned bool
58	// Both is true when both layouts are in the page, and the toggle works in
59	// the browser; otherwise only View is sent.
60	Both                   bool
61	ShowUnified, ShowSplit bool
62	UnifiedHref, SplitHref string
63}
64
65// newDiffView reads ?view= and decides which layouts a diff of that many
66// lines sends; the links switch to the other layout.
67func newDiffView(r *http.Request, lines int, unifiedHref, splitHref string) DiffView {
68	v := DiffView{View: "unified", UnifiedHref: unifiedHref, SplitHref: splitHref}
69	switch r.URL.Query().Get("view") {
70	case "split":
71		v.View, v.Pinned = "split", true
72	case "unified":
73		v.Pinned = true
74	}
75	v.Both = lines <= bothViewsLines
76	v.ShowUnified, v.ShowSplit = v.Both || v.View == "unified", v.Both || v.View == "split"
77	return v
78}
79
80// diffLines counts the lines of the hunks of files, and tells whether some
81// line holds a marker.
82func diffLines(files []DiffFile) (lines int, invisible bool) {
83	for _, f := range files {
84		for _, h := range f.Hunks {
85			lines += len(h.Lines)
86			invisible = invisible || slices.ContainsFunc(h.Lines, hasMarker)
87		}
88	}
89	return lines, invisible
90}
91
92// commit shows a commit: its message, its totals, the changed files and the
93// diff against the first parent. ?view=split selects the split layout.
94func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) {
95	sha := r.PathValue("sha")
96	if !isCommitID(sha) {
97		return nil, 0, errNotFound
98	}
99	d, err := s.loadRepo(ctx, r.PathValue("repo"))
100	if err != nil {
101		return nil, 0, err
102	}
103	res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha}))
104	if err != nil {
105		return nil, 0, err
106	}
107	c := res.Msg.GetCommit()
108	if c == nil {
109		return nil, 0, errNotFound
110	}
111	stats := res.Msg.GetStats()
112	now := s.cfg.Now()
113
114	p := CommitPage{
115		SHA: c.GetId(), Short: short(c.GetId()),
116		Subject: visibleText(c.GetSummary()), Body: commitBody(c),
117		Author: person(c.GetAuthor(), now),
118		Merge:  len(c.GetParentIds()) > 1,
119		Root:   len(c.GetParentIds()) == 0,
120		NFiles: int(stats.GetFilesChanged()),
121		Adds:   int(stats.GetAdditions()),
122		Dels:   int(stats.GetDeletions()),
123		Files:  diffFiles(res.Msg.GetDiff().GetFiles()),
124	}
125	if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() {
126		p.Committer = &cm
127	}
128	for _, id := range c.GetParentIds() {
129		p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)})
130	}
131	for _, b := range d.branches {
132		if b.GetCommitId() == c.GetId() {
133			p.Refs = append(p.Refs, b.GetShortName())
134		}
135	}
136	for _, t := range d.tags {
137		if t.GetCommitId() == c.GetId() {
138			p.Refs = append(p.Refs, "tag: "+t.GetShortName())
139		}
140	}
141	p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles)
142
143	lines, invisible := diffLines(p.Files)
144	p.Invisible = invisible
145	base := repoHref(d.name(), "commit", c.GetId())
146	p.DiffView = newDiffView(r, lines, base+"?view=unified", base+"?view=split")
147
148	l := s.repoLayout(ctx, d, "commits")
149	l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance
150	pinRef(&l, target{rev: c.GetId(), name: p.Short, detached: true})
151	history := repoHref(d.name())
152	if d.head != nil {
153		history = repoHref(d.name(), "commits", d.head.GetShortName())
154	}
155	l.Crumbs = []Crumb{
156		{Label: d.name(), Href: repoHref(d.name())},
157		{Label: "commit", Href: history},
158		{Label: p.Short, Current: true},
159	}
160	l.Status = Status{
161		Mode: "commit", Context: p.Short + " · " + p.totals(),
162		Hints: "d split · u unified · x collapse all · ? help",
163	}
164	l.HasRail, l.RailSum = true, plural(len(p.Files), "changed file", "changed files")
165	return Commit(l, p), http.StatusOK, nil
166}
167
168// totals is "3 files · +12 -4".
169func (p CommitPage) totals() string {
170	return plural(p.NFiles, "file", "files") + " · +" + itoa(p.Adds) + " -" + itoa(p.Dels)
171}
172
173// person converts a signature.
174func person(s *pb.Signature, now time.Time) Person {
175	p := Person{Name: visibleText(s.GetName())}
176	p.When, p.WhenAt = whenOf(now, s.GetTime())
177	if t := s.GetTime(); t.IsValid() {
178		p.Abs = absolute(t.AsTime())
179	}
180	return p
181}
182
183// commitBody is the message without its first line, for display.
184func commitBody(c *pb.Commit) string {
185	msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n")
186	_, body, _ := strings.Cut(msg, "\n")
187	return messageText(strings.TrimSpace(body))
188}
189
190// diffNote says what is missing from an incomplete diff, "" for a complete one.
191func diffNote(d *pb.Diff, shown, total int) string {
192	if !d.GetTruncated() {
193		return ""
194	}
195	var parts []string
196	if shown < total {
197		parts = append(parts, plural(total-shown, "file is", "files are")+" not listed")
198	}
199	cut := 0
200	for _, f := range d.GetFiles() {
201		if f.GetTruncated() {
202			cut++
203		}
204	}
205	if cut > 0 {
206		parts = append(parts, plural(cut, "file is", "files are")+" too large to show")
207	}
208	return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest."
209}

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