v0.9.0 dd8597a

fragments.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"strconv"
10	"strings"
11	"sync"
12	"time"
13
14	"github.com/a-h/templ"
15
16	"bnema.dev/ermite/internal/domain"
17	"bnema.dev/ermite/internal/ports"
18)
19
20// liveStream turns the events of Watch into the fragments one page shows.
21type liveStream struct {
22	s    *Server
23	repo string // "" on the home page
24	view string // "summary", "refs" or "commits"
25	ref  string // commits: the branch listed
26	head string // commits: the commit listed first
27	n    string // mr: the number of the merge request shown; run: the run id
28	job  string // job: the id of the job shown
29	// sha is the commit of the run of the job shown, read once when the stream
30	// is set up: the annotations link to files at it, and it never changes.
31	sha string
32}
33
34// renderFragment renders a component to the data of an event.
35func renderFragment(ctx context.Context, event string, c templ.Component) (fragment, error) {
36	var b bytes.Buffer
37	if err := c.Render(ctx, &b); err != nil {
38		return fragment{}, err
39	}
40	return fragment{event: event, html: b.String()}, nil
41}
42
43var gap = []fragment{{event: "gap", html: "gap"}}
44
45// liveEvent reports whether the event of a watch is one the pages follow: a
46// gap, or one of the events of the forge. The use case may send others, which
47// no page shows and no stream carries, not even their id.
48func liveEvent(we ports.WatchEvent) bool {
49	if we.Gap {
50		return true
51	}
52	switch we.Event.(type) {
53	case domain.RepoCreated, domain.RepoUpdated, domain.RepoDeleted, domain.PushEvent,
54		domain.MergeRequestOpened, domain.MergeRequestMerged, domain.MergeRequestClosed, domain.CommentAdded,
55		domain.RunUpdated, domain.JobUpdated:
56		return true
57	}
58	return false
59}
60
61// render returns the fragments for an event, none when it changes nothing on
62// the page.
63func (l *liveStream) render(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
64	if we.Gap {
65		return gap, nil
66	}
67	if we.Event == nil {
68		return nil, nil
69	}
70	if l.repo == "" {
71		return l.home(ctx, we)
72	}
73	return l.repoPage(ctx, we)
74}
75
76// repoByName reads a repository as the caller of the request. A name that is
77// not valid is a repository that is not there.
78func (l *liveStream) repoByName(ctx context.Context, rawName string) (ports.RepoView, error) {
79	name, err := domain.ParseRepoName(rawName)
80	if err != nil {
81		return ports.RepoView{}, domain.ErrNotFound
82	}
83	return l.s.repoView(ctx, name)
84}
85
86// home upserts the row of the repository an event is about. The row is read
87// again from the use case as the principal of the stream, so a repository
88// that was deleted or that the visitor can no longer see (public to private)
89// removes the row, and the stream never carries more than the list shows.
90func (l *liveStream) home(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
91	var name string
92	fresh := false
93	switch ev := we.Event.(type) {
94	case domain.RepoDeleted:
95		return l.gone(ctx, ev.Repo.String())
96	case domain.RepoCreated:
97		name = ev.Repo.String()
98	case domain.RepoUpdated:
99		name = ev.Repo.String()
100	case domain.PushEvent:
101		name, fresh = ev.Repo.String(), true
102	default:
103		return nil, nil
104	}
105	view, err := l.repoByName(ctx, name)
106	switch {
107	case errors.Is(err, domain.ErrNotFound):
108		return l.gone(ctx, name)
109	case err != nil:
110		return nil, err
111	}
112	row := homeRow(view, l.s.cfg.Now(), false)
113	row.Fresh = fresh
114	f, err := renderFragment(ctx, "home-list", HomeRowView(row))
115	return []fragment{f}, err
116}
117
118func (l *liveStream) gone(ctx context.Context, name string) ([]fragment, error) {
119	f, err := renderFragment(ctx, "home-list", RepoGone("repo-"+name))
120	return []fragment{f}, err
121}
122
123// repoPage handles the events of the topic of one repository.
124func (l *liveStream) repoPage(ctx context.Context, we ports.WatchEvent) ([]fragment, error) {
125	switch ev := we.Event.(type) {
126	case domain.RunUpdated:
127		return l.runEvent(ctx, ev)
128	case domain.JobUpdated:
129		return l.jobEvent(ctx, ev)
130	case domain.RepoDeleted, domain.RepoCreated:
131		// Gone, or a new repository under the same name: whatever the page
132		// shows is not true any more.
133		return gap, nil
134	case domain.RepoUpdated:
135		_, err := l.repoByName(ctx, l.repo)
136		switch {
137		case errors.Is(err, domain.ErrNotFound):
138			return gap, nil // no longer visible
139		case err != nil:
140			return nil, err
141		case l.view == "summary", l.view == "refs":
142			return gap, nil // the description, the default branch
143		}
144		return nil, nil
145	case domain.MergeRequestOpened:
146		return l.mrNotice(ctx, ev.MergeRequest.Number, "a merge request was opened — reload")
147	case domain.MergeRequestMerged:
148		return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was merged — reload")
149	case domain.MergeRequestClosed:
150		return l.mrNotice(ctx, ev.MergeRequest.Number, "this merge request was closed — reload")
151	case domain.CommentAdded:
152		return l.mrNotice(ctx, ev.Number, "a comment was added — reload")
153	case domain.PushEvent:
154		switch l.view {
155		case "mr":
156			return l.mrPushed(ctx, ev)
157		case "mrs":
158			return nil, nil
159		}
160		switch l.view {
161		case "refs":
162			return l.refs(ctx, we.ID)
163		case "commits":
164			return l.banner(ctx, ev)
165		}
166		return l.lastPush(ctx)
167	}
168	return nil, nil
169}
170
171// lastPush is the "last push" fact of the summary.
172func (l *liveStream) lastPush(ctx context.Context) ([]fragment, error) {
173	view, err := l.repoByName(ctx, l.repo)
174	if err != nil {
175		return nil, err
176	}
177	f, err := renderFragment(ctx, "repo-last-push", FactValue(lastPushFact(view, l.s.cfg.Now())))
178	return []fragment{f}, err
179}
180
181// refs is the table of the refs page and the facts of its rail. The fragments
182// of one event are built once for all the watchers of the repository that
183// see the same things (see fanout); eventID names the event.
184func (l *liveStream) refs(ctx context.Context, eventID string) ([]fragment, error) {
185	key := fanKey{repo: l.repo, event: eventID, owner: SignedIn(ctx)}
186	return l.s.refsFan.do(ctx, key, func() ([]fragment, error) {
187		d, err := l.s.loadRepo(ctx, l.repo)
188		if err != nil {
189			return nil, err
190		}
191		p := l.s.refsPage(ctx, d)
192		body, err := renderFragment(ctx, "refs-list", RefsBody(p))
193		if err != nil {
194			return nil, err
195		}
196		kv, err := renderFragment(ctx, "refs-list", RefsKV(p))
197		return []fragment{body, kv}, err
198	})
199}
200
201const (
202	// fanTTL is how long the fragments of an event are kept for the watchers
203	// that are told of it a little later than the first.
204	fanTTL = 2 * time.Second
205	// fanMax bounds the events kept; past it an event is built for each
206	// watcher, as without sharing.
207	fanMax = 64
208)
209
210// fanKey says which watchers may share the fragments of an event: those of
211// the same repository, for the same event, and of the same class. The class
212// is anonymous or owner (a session is the owner's alone, ADR 0018): what is
213// built as the owner can show what an anonymous visitor may not see, so the
214// two never share an entry.
215type fanKey struct {
216	repo, event string
217	owner       bool
218}
219
220type fanEntry struct {
221	done  chan struct{} // closed once frags or err is set
222	frags []fragment
223	err   error
224	at    time.Time // when it was built; zero while it is
225}
226
227// fanout builds the fragments of an event once for the many watchers that
228// receive it: a push to a repository that 256 pages watch would otherwise
229// load the refs and the commits they designate 256 times. The zero value is
230// ready. A failure is never kept: the next watcher builds it again.
231type fanout struct {
232	mu sync.Mutex
233	m  map[fanKey]*fanEntry
234}
235
236// do returns the fragments of key, built by build when nobody has or is
237// building them. build runs with the context of the watcher that calls it,
238// which is of the class of the key; a watcher whose builder failed (its
239// request was cancelled, say) does not fail the others, they build again.
240func (f *fanout) do(ctx context.Context, key fanKey, build func() ([]fragment, error)) ([]fragment, error) {
241	if key.event == "" {
242		return build() // no event to share by
243	}
244	for range 3 {
245		f.mu.Lock()
246		now := time.Now()
247		for k, e := range f.m {
248			if !e.at.IsZero() && now.Sub(e.at) > fanTTL {
249				delete(f.m, k)
250			}
251		}
252		e, shared := f.m[key]
253		if !shared {
254			if len(f.m) >= fanMax {
255				f.mu.Unlock()
256				return build()
257			}
258			if f.m == nil {
259				f.m = make(map[fanKey]*fanEntry)
260			}
261			e = &fanEntry{done: make(chan struct{})}
262			f.m[key] = e
263		}
264		f.mu.Unlock()
265
266		if !shared {
267			return f.lead(key, e, build)
268		}
269		select {
270		case <-e.done:
271		case <-ctx.Done():
272			return nil, ctx.Err()
273		}
274		if e.err == nil {
275			return e.frags, nil
276		}
277	}
278	return build()
279}
280
281// lead builds the entry that the caller registered.
282func (f *fanout) lead(key fanKey, e *fanEntry, build func() ([]fragment, error)) (frags []fragment, err error) {
283	err = errors.New("web: fragments were not built") // a panic of build
284	defer func() {
285		f.mu.Lock()
286		e.frags, e.err, e.at = frags, err, time.Now()
287		if err != nil && f.m[key] == e {
288			delete(f.m, key)
289		}
290		f.mu.Unlock()
291		close(e.done)
292	}()
293	return build()
294}
295
296// banner tells the commits page how many commits its branch got since the
297// head it lists.
298//
299//hexcheck:ignore suspicious-business-logic-in-adapter counts the commits of a page for the banner text; display only
300func (l *liveStream) banner(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
301	if l.ref == "" || !isFullID(l.head) {
302		return nil, nil
303	}
304	tip, touched := "", false
305	for _, u := range p.Updates {
306		if u.Ref == "refs/heads/"+l.ref {
307			tip, touched = u.New, true
308			if u.IsDelete() {
309				tip = ""
310			}
311		}
312	}
313	switch {
314	case !touched:
315		return nil, nil
316	case tip == "":
317		return gap, nil // the branch was deleted
318	}
319	text := ""
320	if tip != l.head {
321		name, err := domain.ParseRepoName(l.repo)
322		if err != nil {
323			return nil, err
324		}
325		res, err := l.s.commitList(ctx, name, tip, "", pageSize, "")
326		if err != nil {
327			return nil, err
328		}
329		// The count is approximate for a history with merges: the log is
330		// ordered by date, not as "head..tip", so commits that are not new may
331		// be counted before the head is met. And one page of the log being
332		// all that is read, "100+" cannot be told from a rewritten branch
333		// whose old head is further back: the use cases have no "is ancestor".
334		n, found := 0, false
335		for _, c := range res.Commits {
336			if c.ID.String() == l.head {
337				found = true
338				break
339			}
340			n++
341		}
342		switch {
343		case found:
344			text = newCommits(strconv.Itoa(n), n)
345		case res.NextPageToken != "":
346			text = newCommits(strconv.Itoa(n)+"+", 2)
347		default:
348			text = "the branch was rewritten — reload"
349		}
350	}
351	c := LiveSlot("commits-head")
352	if text != "" {
353		c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
354	}
355	f, err := renderFragment(ctx, "commits-head", c)
356	return []fragment{f}, err
357}
358
359// newCommits is the text of the banner: "1 new commit — reload".
360func newCommits(count string, n int) string {
361	if n == 1 {
362		return count + " new commit — reload"
363	}
364	return count + " new commits — reload"
365}
366
367// isFullID reports whether s is a full commit id.
368func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
369
370// mrNotice tells a merge request page that something happened to the request
371// it shows, or the list that its content changed. Only a fixed sentence is sent:
372// the text of a comment or a title never leaves through the stream.
373func (l *liveStream) mrNotice(ctx context.Context, number int64, text string) ([]fragment, error) {
374	switch l.view {
375	case "mrs":
376		text = "merge requests changed — reload"
377		return l.notice(ctx, text, mrBase(l.repo))
378	case "mr":
379		if strconv.FormatInt(number, 10) != l.n {
380			return nil, nil
381		}
382		return l.notice(ctx, text, mrHref(l.repo, number))
383	}
384	return nil, nil
385}
386
387// mrPushed announces a push to the source branch of the merge request shown.
388func (l *liveStream) mrPushed(ctx context.Context, p domain.PushEvent) ([]fragment, error) {
389	if l.ref == "" {
390		return nil, nil
391	}
392	for _, u := range p.Updates {
393		if u.Ref == "refs/heads/"+l.ref {
394			n, err := strconv.ParseInt(l.n, 10, 64)
395			if err != nil {
396				return nil, nil
397			}
398			return l.notice(ctx, "the branch changed — reload", mrHref(l.repo, n))
399		}
400	}
401	return nil, nil
402}
403
404func (l *liveStream) notice(ctx context.Context, text, href string) ([]fragment, error) {
405	f, err := renderFragment(ctx, "mr-notice", MRNotice(text, href))
406	return []fragment{f}, err
407}
408
409// runEvent updates the pages of CI that a RunUpdated changes: the row of the
410// list of runs, or the body of the run page shown. Only a state is sent: the
411// events carry no log and no text of the workflow.
412func (l *liveStream) runEvent(ctx context.Context, ev domain.RunUpdated) ([]fragment, error) {
413	if l.s.cfg.Runs == nil {
414		return nil, nil
415	}
416	switch l.view {
417	case "actions":
418		f, err := renderFragment(ctx, "ci-run", RunListRow(l.s.runRow(l.repo, ev.Run, l.s.cfg.Now())))
419		if err != nil {
420			return nil, err
421		}
422		if ev.Run.Status == domain.CIQueued {
423			f.html = strings.Replace(f.html, "<tr id=", "<tr data-new id=", 1)
424		}
425		return []fragment{f}, nil
426	case "run":
427		if strconv.FormatInt(ev.Run.ID, 10) != l.n {
428			return nil, nil
429		}
430		return l.runBody(ctx)
431	}
432	return nil, nil
433}
434
435// jobEvent updates the body of the run page and the steps of the job page.
436func (l *liveStream) jobEvent(ctx context.Context, ev domain.JobUpdated) ([]fragment, error) {
437	if l.s.cfg.Runs == nil {
438		return nil, nil
439	}
440	switch l.view {
441	case "run":
442		if strconv.FormatInt(ev.Job.RunID, 10) != l.n {
443			return nil, nil
444		}
445		return l.runBody(ctx)
446	case "job":
447		if ev.Job.ID != l.job {
448			return nil, nil
449		}
450		return l.jobState(ctx)
451	}
452	return nil, nil
453}
454
455// runBody renders again the jobs of the run page.
456func (l *liveStream) runBody(ctx context.Context) ([]fragment, error) {
457	id, err := strconv.ParseInt(l.n, 10, 64)
458	if err != nil {
459		return nil, nil
460	}
461	d, err := l.s.loadRepo(ctx, l.repo)
462	if err != nil {
463		return nil, err
464	}
465	p, err := l.s.runPage(ctx, d, id, 0)
466	if err != nil {
467		return nil, err
468	}
469	body, err := renderFragment(ctx, "ci-run", runBodyFragment(p))
470	if err != nil {
471		return nil, err
472	}
473	// The buttons depend on the state: the page fetches itself again when the
474	// run completed, so that the owner gets the right ones.
475	if !p.Active {
476		return []fragment{body, {event: "gap", html: "gap"}}, nil
477	}
478	return []fragment{body}, nil
479}
480
481// jobState tells the job page that its state or its steps changed: the page
482// reloads the part of the log after the last line it holds (ermite.js).
483func (l *liveStream) jobState(ctx context.Context) ([]fragment, error) {
484	if l.sha == "" {
485		return gap, nil // the stream was set up without the commit of the run
486	}
487	d, err := l.s.loadRepo(ctx, l.repo)
488	if err != nil {
489		return nil, err
490	}
491	view, err := l.s.cfg.Runs.GetJob(ctx, principal(ctx), d.repo.Name, l.job, 0)
492	if err != nil {
493		return nil, err
494	}
495	now := l.s.cfg.Now()
496	row := l.s.jobRow(d.name(), view, now)
497	f, err := renderFragment(ctx, "ci-job", JobHead(row, view.Lines))
498	if err != nil {
499		return nil, err
500	}
501	steps, err := renderFragment(ctx, "ci-job", JobSteps(stepViews(view, now)))
502	if err != nil {
503		return nil, err
504	}
505	anns, more, err := l.s.jobAnnotations(ctx, d, view.RunID, l.sha, view, 0)
506	if err != nil {
507		return nil, err
508	}
509	notes, err := renderFragment(ctx, "ci-job", JobAnnotations(anns, more))
510	if err != nil {
511		return nil, err
512	}
513	return []fragment{f, steps, notes}, nil
514}

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