v0.1.0 3f267b5

fragments.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"context"
8	"strconv"
9
10	"connectrpc.com/connect"
11	"github.com/a-h/templ"
12
13	pb "bnema.dev/ermite-api/gen/ermite"
14)
15
16// liveStream turns the events of Watch into the fragments one page shows.
17type liveStream struct {
18	s    *Server
19	repo string // "" on the home page
20	view string // "summary", "refs" or "commits"
21	ref  string // commits: the branch listed
22	head string // commits: the commit listed first
23}
24
25// renderFragment renders a component to the data of an event.
26func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
27	var b bytes.Buffer
28	if err := c.Render(ctx, &b); err != nil {
29		return fragment{}, err
30	}
31	return fragment{event: event, html: b.String()}, nil
32}
33
34var gap = []fragment{{event: "gap", html: "gap"}}
35
36// render returns the fragments for an event, none when it changes nothing on
37// the page.
38func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) {
39	if ev == nil {
40		return nil, nil
41	}
42	if ev.GetGap() != nil {
43		return gap, nil
44	}
45	if l.repo == "" {
46		return l.home(ctx, ev)
47	}
48	return l.repoPage(ctx, ev)
49}
50
51// home upserts the row of the repository an event is about. The row is read
52// again from the API as the visitor of the stream, so a repository that was
53// deleted or that the visitor can no longer see (public to private) removes
54// the row, and the stream never carries more than the list shows.
55func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) {
56	var name string
57	fresh := false
58	switch k := ev.GetKind().(type) {
59	case *pb.Event_RepoDeleted:
60		return l.gone(ctx, k.RepoDeleted.GetRepo())
61	case *pb.Event_RepoCreated:
62		name = k.RepoCreated.GetRepo().GetName()
63	case *pb.Event_RepoUpdated:
64		name = k.RepoUpdated.GetRepo().GetName()
65	case *pb.Event_Pushed:
66		name, fresh = k.Pushed.GetRepo(), true
67	default:
68		return nil, nil
69	}
70	res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
71	switch {
72	case connect.CodeOf(err) == connect.CodeNotFound:
73		return l.gone(ctx, name)
74	case err != nil:
75		return nil, err
76	}
77	row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false)
78	row.Fresh = fresh
79	f, err := renderFragment(ctx, "home-list", HomeRowView(row))
80	return []fragment{f}, err
81}
82
83func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) {
84	f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name))
85	return []fragment{f}, err
86}
87
88// repoPage handles the events of the topic of one repository.
89func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) {
90	switch k := ev.GetKind().(type) {
91	case *pb.Event_RepoDeleted, *pb.Event_RepoCreated:
92		// Gone, or a new repository under the same name: whatever the page
93		// shows is not true any more.
94		return gap, nil
95	case *pb.Event_RepoUpdated:
96		_, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
97		switch {
98		case connect.CodeOf(err) == connect.CodeNotFound:
99			return gap, nil // no longer visible
100		case err != nil:
101			return nil, err
102		case l.view == "summary", l.view == "refs":
103			return gap, nil // the description, the default branch
104		}
105		return nil, nil
106	case *pb.Event_Pushed:
107		switch l.view {
108		case "refs":
109			return l.refs(ctx)
110		case "commits":
111			return l.banner(ctx, k.Pushed)
112		}
113		return l.lastPush(ctx)
114	}
115	return nil, nil
116}
117
118// lastPush is the "last push" fact of the summary.
119func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) {
120	res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
121	if err != nil {
122		return nil, err
123	}
124	f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now())))
125	return []fragment{f}, err
126}
127
128// refs is the table of the refs page and the facts of its rail.
129func (l *liveStream) refs(ctx context.Context) ([]fragment, error) {
130	d, err := l.s.loadRepo(ctx, l.repo)
131	if err != nil {
132		return nil, err
133	}
134	p := l.s.refsPage(ctx, d)
135	body, err := renderFragment(ctx, "refs-list", RefsBody(p))
136	if err != nil {
137		return nil, err
138	}
139	kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
140	return []fragment{body, kv}, err
141}
142
143// banner tells the commits page how many commits its branch got since the
144// head it lists.
145func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) {
146	if l.ref == "" || !isFullID(l.head) {
147		return nil, nil
148	}
149	tip, touched := "", false
150	for _, u := range p.GetRefUpdates() {
151		if u.GetRef() == "refs/heads/"+l.ref {
152			tip, touched = u.GetNewId(), true
153		}
154	}
155	switch {
156	case !touched:
157		return nil, nil
158	case tip == "":
159		return gap, nil // the branch was deleted
160	}
161	text := ""
162	if tip != l.head {
163		res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
164			Repo: l.repo, Revision: tip, PageSize: pageSize,
165		}))
166		if err != nil {
167			return nil, err
168		}
169		// The count is approximate for a history with merges: the log is
170		// ordered by date, not as "head..tip", so commits that are not new may
171		// be counted before the head is met. And one page of the log being
172		// all that is read, "100+" cannot be told from a rewritten branch
173		// whose old head is further back: the API has no "is ancestor".
174		n, found := 0, false
175		for _, c := range res.Msg.GetCommits() {
176			if c.GetId() == l.head {
177				found = true
178				break
179			}
180			n++
181		}
182		switch {
183		case found:
184			text = newCommits(strconv.Itoa(n), n)
185		case res.Msg.GetNextPageToken() != "":
186			text = newCommits(strconv.Itoa(n)+"+", 2)
187		default:
188			text = "the branch was rewritten — reload"
189		}
190	}
191	c := CommitsSlot()
192	if text != "" {
193		c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
194	}
195	f, err := renderFragment(ctx, "commits-head", c)
196	return []fragment{f}, err
197}
198
199// newCommits is the text of the banner: "1 new commit — reload".
200func newCommits(count string, n int) string {
201	if n == 1 {
202		return count + " new commit — reload"
203	}
204	return count + " new commits — reload"
205}
206
207// isFullID reports whether s is a full commit id.
208func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }

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