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) }