v0.2.0 41cf2ec

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	n    string // mr: the number of the merge request shown
24}
25
26// renderFragment renders a component to the data of an event.
27func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
28	var b bytes.Buffer
29	if err := c.Render(ctx, &b); err != nil {
30		return fragment{}, err
31	}
32	return fragment{event: event, html: b.String()}, nil
33}
34
35var gap = []fragment{{event: "gap", html: "gap"}}
36
37// render returns the fragments for an event, none when it changes nothing on
38// the page.
39func (l *liveStream) render(ctx context.Context, ev *pb.Event) ([]fragment, error) {
40	if ev == nil {
41		return nil, nil
42	}
43	if ev.GetGap() != nil {
44		return gap, nil
45	}
46	if l.repo == "" {
47		return l.home(ctx, ev)
48	}
49	return l.repoPage(ctx, ev)
50}
51
52// home upserts the row of the repository an event is about. The row is read
53// again from the API as the visitor of the stream, so a repository that was
54// deleted or that the visitor can no longer see (public to private) removes
55// the row, and the stream never carries more than the list shows.
56func (l *liveStream) home(ctx context.Context, ev *pb.Event) ([]fragment, error) {
57	var name string
58	fresh := false
59	switch k := ev.GetKind().(type) {
60	case *pb.Event_RepoDeleted:
61		return l.gone(ctx, k.RepoDeleted.GetRepo())
62	case *pb.Event_RepoCreated:
63		name = k.RepoCreated.GetRepo().GetName()
64	case *pb.Event_RepoUpdated:
65		name = k.RepoUpdated.GetRepo().GetName()
66	case *pb.Event_Pushed:
67		name, fresh = k.Pushed.GetRepo(), true
68	default:
69		return nil, nil
70	}
71	res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: name}))
72	switch {
73	case connect.CodeOf(err) == connect.CodeNotFound:
74		return l.gone(ctx, name)
75	case err != nil:
76		return nil, err
77	}
78	row := homeRow(res.Msg.GetRepo(), l.s.cfg.Now(), false)
79	row.Fresh = fresh
80	f, err := renderFragment(ctx, "home-list", HomeRowView(row))
81	return []fragment{f}, err
82}
83
84func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) {
85	f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name))
86	return []fragment{f}, err
87}
88
89// repoPage handles the events of the topic of one repository.
90func (l *liveStream) repoPage(ctx context.Context, ev *pb.Event) ([]fragment, error) {
91	switch k := ev.GetKind().(type) {
92	case *pb.Event_RepoDeleted, *pb.Event_RepoCreated:
93		// Gone, or a new repository under the same name: whatever the page
94		// shows is not true any more.
95		return gap, nil
96	case *pb.Event_RepoUpdated:
97		_, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
98		switch {
99		case connect.CodeOf(err) == connect.CodeNotFound:
100			return gap, nil // no longer visible
101		case err != nil:
102			return nil, err
103		case l.view == "summary", l.view == "refs":
104			return gap, nil // the description, the default branch
105		}
106		return nil, nil
107	case *pb.Event_MergeRequestOpened:
108		return l.mrNotice(ctx, k.MergeRequestOpened.GetMergeRequest().GetNumber(), "a merge request was opened — reload")
109	case *pb.Event_MergeRequestMerged:
110		return l.mrNotice(ctx, k.MergeRequestMerged.GetMergeRequest().GetNumber(), "this merge request was merged — reload")
111	case *pb.Event_MergeRequestClosed:
112		return l.mrNotice(ctx, k.MergeRequestClosed.GetMergeRequest().GetNumber(), "this merge request was closed — reload")
113	case *pb.Event_CommentAdded:
114		return l.mrNotice(ctx, k.CommentAdded.GetNumber(), "a comment was added — reload")
115	case *pb.Event_Pushed:
116		switch l.view {
117		case "mr":
118			return l.mrPushed(ctx, k.Pushed)
119		case "mrs":
120			return nil, nil
121		}
122		switch l.view {
123		case "refs":
124			return l.refs(ctx)
125		case "commits":
126			return l.banner(ctx, k.Pushed)
127		}
128		return l.lastPush(ctx)
129	}
130	return nil, nil
131}
132
133// lastPush is the "last push" fact of the summary.
134func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) {
135	res, err := l.s.cfg.Repos.GetRepo(ctx, connect.NewRequest(&pb.GetRepoRequest{Repo: l.repo}))
136	if err != nil {
137		return nil, err
138	}
139	f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(res.Msg.GetRepo(), l.s.cfg.Now())))
140	return []fragment{f}, err
141}
142
143// refs is the table of the refs page and the facts of its rail.
144func (l *liveStream) refs(ctx context.Context) ([]fragment, error) {
145	d, err := l.s.loadRepo(ctx, l.repo)
146	if err != nil {
147		return nil, err
148	}
149	p := l.s.refsPage(ctx, d)
150	body, err := renderFragment(ctx, "refs-list", RefsBody(p))
151	if err != nil {
152		return nil, err
153	}
154	kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
155	return []fragment{body, kv}, err
156}
157
158// banner tells the commits page how many commits its branch got since the
159// head it lists.
160func (l *liveStream) banner(ctx context.Context, p *pb.Pushed) ([]fragment, error) {
161	if l.ref == "" || !isFullID(l.head) {
162		return nil, nil
163	}
164	tip, touched := "", false
165	for _, u := range p.GetRefUpdates() {
166		if u.GetRef() == "refs/heads/"+l.ref {
167			tip, touched = u.GetNewId(), true
168		}
169	}
170	switch {
171	case !touched:
172		return nil, nil
173	case tip == "":
174		return gap, nil // the branch was deleted
175	}
176	text := ""
177	if tip != l.head {
178		res, err := l.s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{
179			Repo: l.repo, Revision: tip, PageSize: pageSize,
180		}))
181		if err != nil {
182			return nil, err
183		}
184		// The count is approximate for a history with merges: the log is
185		// ordered by date, not as "head..tip", so commits that are not new may
186		// be counted before the head is met. And one page of the log being
187		// all that is read, "100+" cannot be told from a rewritten branch
188		// whose old head is further back: the API has no "is ancestor".
189		n, found := 0, false
190		for _, c := range res.Msg.GetCommits() {
191			if c.GetId() == l.head {
192				found = true
193				break
194			}
195			n++
196		}
197		switch {
198		case found:
199			text = newCommits(strconv.Itoa(n), n)
200		case res.Msg.GetNextPageToken() != "":
201			text = newCommits(strconv.Itoa(n)+"+", 2)
202		default:
203			text = "the branch was rewritten — reload"
204		}
205	}
206	c := CommitsSlot()
207	if text != "" {
208		c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
209	}
210	f, err := renderFragment(ctx, "commits-head", c)
211	return []fragment{f}, err
212}
213
214// newCommits is the text of the banner: "1 new commit — reload".
215func newCommits(count string, n int) string {
216	if n == 1 {
217		return count + " new commit — reload"
218	}
219	return count + " new commits — reload"
220}
221
222// isFullID reports whether s is a full commit id.
223func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
224
225// mrNotice tells a merge request page that something happened to the request
226// it shows, or the list that its content changed. Only a fixed sentence is sent:
227// the text of a comment or a title never leaves through the stream.
228func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
229	switch l.view {
230	case "mrs":
231		text = "merge requests changed — reload"
232		return l.notice(ctx, text, mrBase(l.repo))
233	case "mr":
234		if strconv.FormatInt(number, 10) != l.n {
235			return nil, nil
236		}
237		return l.notice(ctx, text, mrHref(l.repo, number))
238	}
239	return nil, nil
240}
241
242// mrPushed announces a push to the source branch of the merge request shown.
243func (l *liveStream) mrPushed(ctx context.Context, p *pb.Pushed) ([]fragment, error) {
244	if l.ref == "" {
245		return nil, nil
246	}
247	for _, u := range p.GetRefUpdates() {
248		if u.GetRef() == "refs/heads/"+l.ref {
249			n, err := strconv.ParseInt(l.n, 10, 64)
250			if err != nil {
251				return nil, nil
252			}
253			return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
254		}
255	}
256	return nil, nil
257}
258
259func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
260	f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
261	return []fragment{f}, err
262}

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