detached 2cfcc85

feat(web): stream live page fragments over SSE

GET /-/events follows EventService.Watch as the visitor of the request and
sends the HTML fragments the page shows as Server-Sent Events:

- topic=home: home-list, the row of a repository (new push, created,
  updated), or a data-gone row when it is deleted or no longer visible;
- topic=repo:<name>&view=summary: repo-last-push;
- view=refs: refs-list (the table and the facts of the rail);
- view=commits&ref=<branch>&head=<sha>: commits-head, the "N new commits —
  reload" banner counted from the head the page lists;
- gap: events were lost, or what the page shows is gone: the page reloads.

Event ids are the Watch ids, Last-Event-ID is resume_after, an idle stream
gets a comment every 25 s (Config.Heartbeat), each event is flushed and
the write deadline is extended per write, so a server write timeout does not
end the stream. A private or unknown repository is a 404, a refused watch
(resource_exhausted) a 503 with Retry-After, a bad topic a 400.

The pages set Layout.Live and LiveURL; the commits page gets the
#commits-head slot on the first page of a branch tip only. ermiteLive in
ermite.js swaps the fragments by id. The fragments live in fragments.templ,
the handler in events.go.
commit
2cfcc8579f0069987c36f474f9fef609e3151f27
author
brice · · 2026-10-10 13:38 UTC
committer
brice · · 2026-10-10 13:39 UTC
parent
fb91573
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
30 files · +2109 -513
M cmd/ermite/testdata/web/commits-page2.html +1 -1
@@ -61,7 +61,7 @@
6161 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6262 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6363 </div>
64-<div class="days" id="commits-head">
64+<div class="days">
6565 <section aria-label="2026-03-01">
6666 <h2 class="rule-h">
6767 <span>2026-03-01</span>
@@ -61,7 +61,7 @@
61 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));61 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">62 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63 </div>63 </div>
64-<div class="days" id="commits-head">64+<div class="days">
65 <section aria-label="2026-03-01">65 <section aria-label="2026-03-01">
66 <h2 class="rule-h">66 <h2 class="rule-h">
67 <span>2026-03-01</span>67 <span>2026-03-01</span>
M cmd/ermite/testdata/web/commits-path.html +1 -1
@@ -65,7 +65,7 @@
6565 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6666 </div>
6767 <p class="lede dim">history of <code>a.txt</code> at main</p>
68-<div class="days" id="commits-head">
68+<div class="days">
6969 <section aria-label="2026-03-16">
7070 <h2 class="rule-h">
7171 <span>2026-03-16</span>
@@ -65,7 +65,7 @@
65 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">65 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
66 </div>66 </div>
67 <p class="lede dim">history of <code>a.txt</code> at main</p>67 <p class="lede dim">history of <code>a.txt</code> at main</p>
68-<div class="days" id="commits-head">68+<div class="days">
69 <section aria-label="2026-03-16">69 <section aria-label="2026-03-16">
70 <h2 class="rule-h">70 <h2 class="rule-h">
71 <span>2026-03-16</span>71 <span>2026-03-16</span>
M cmd/ermite/testdata/web/commits-root.html +7 -2
@@ -61,7 +61,9 @@
6161 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6262 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6363 </div>
64-<div class="days" id="commits-head">
64+<div id="commits-head">
65+</div>
66+<div class="days">
6567 <section aria-label="2026-03-26">
6668 <h2 class="rule-h">
6769 <span>2026-03-26</span>
@@ -1085,7 +1087,10 @@
10851087 <span class="st-ver">ermite devel · </span>
10861088 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
10871089 </span>
1088-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
1090+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Along&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
1091+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
1092+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
1093+ gap -> ermiteLive.reload()">offline</span>
10891094 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>
10901095 </footer>
10911096 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
@@ -61,7 +61,9 @@
61 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));61 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">62 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63 </div>63 </div>
64-<div class="days" id="commits-head">64+<div id="commits-head">
65+</div>
66+<div class="days">
65 <section aria-label="2026-03-26">67 <section aria-label="2026-03-26">
66 <h2 class="rule-h">68 <h2 class="rule-h">
67 <span>2026-03-26</span>69 <span>2026-03-26</span>
@@ -1085,7 +1087,10 @@
1085 <span class="st-ver">ermite devel · </span>1087 <span class="st-ver">ermite devel · </span>
1086 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>1088 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
1087 </span>1089 </span>
1088-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>1090+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Along&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
1091+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
1092+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
1093+ gap -> ermiteLive.reload()">offline</span>
1089 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>1094 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/50</span>
1090 </footer>1095 </footer>
1091 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {1096 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
M cmd/ermite/testdata/web/commits-small.html +7 -2
@@ -61,7 +61,9 @@
6161 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
6262 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
6363 </div>
64-<div class="days" id="commits-head">
64+<div id="commits-head">
65+</div>
66+<div class="days">
6567 <section aria-label="2026-02-08">
6668 <h2 class="rule-h">
6769 <span>2026-02-08</span>
@@ -445,7 +447,10 @@
445447 <span class="st-ver">ermite devel · </span>
446448 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
447449 </span>
448-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
450+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Adiffs&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
451+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
452+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
453+ gap -> ermiteLive.reload()">offline</span>
449454 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>
450455 </footer>
451456 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
@@ -61,7 +61,9 @@
61 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));61 q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])'));
62 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">62 let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')">
63 </div>63 </div>
64-<div class="days" id="commits-head">64+<div id="commits-head">
65+</div>
66+<div class="days">
65 <section aria-label="2026-02-08">67 <section aria-label="2026-02-08">
66 <h2 class="rule-h">68 <h2 class="rule-h">
67 <span>2026-02-08</span>69 <span>2026-02-08</span>
@@ -445,7 +447,10 @@
445 <span class="st-ver">ermite devel · </span>447 <span class="st-ver">ermite devel · </span>
446 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>448 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
447 </span>449 </span>
448-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>450+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Adiffs&amp;view=commits&amp;ref=main&amp;head=SHA40" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
451+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
452+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
453+ gap -> ermiteLive.reload()">offline</span>
449 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>454 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/8</span>
450 </footer>455 </footer>
451 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {456 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
M cmd/ermite/testdata/web/home.html +4 -1
@@ -218,7 +218,10 @@
218218 <span class="st-ver">ermite devel · </span>
219219 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
220220 </span>
221-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
221+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
222+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
223+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
224+ gap -> ermiteLive.reload()">offline</span>
222225 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
223226 </footer>
224227 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
@@ -218,7 +218,10 @@
218 <span class="st-ver">ermite devel · </span>218 <span class="st-ver">ermite devel · </span>
219 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>219 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
220 </span>220 </span>
221-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>221+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=home" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
222+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
223+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
224+ gap -> ermiteLive.reload()">offline</span>
222 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>225 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
223 </footer>226 </footer>
224 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {227 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
M cmd/ermite/testdata/web/refs.html +5 -2
@@ -139,7 +139,7 @@
139139 <summary>refs · legend</summary>
140140 <section aria-labelledby="r-refs">
141141 <h2 id="r-refs">refs</h2>
142-<dl class="kv">
142+<dl class="kv" id="refs-kv">
143143 <dt>default</dt>
144144 <dd>main</dd>
145145 <dt>branches</dt>
@@ -354,7 +354,10 @@
354354 <span class="st-ver">ermite devel · </span>
355355 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
356356 </span>
357-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
357+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
358+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
359+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
360+ gap -> ermiteLive.reload()">offline</span>
358361 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>
359362 </footer>
360363 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
@@ -139,7 +139,7 @@
139 <summary>refs · legend</summary>139 <summary>refs · legend</summary>
140 <section aria-labelledby="r-refs">140 <section aria-labelledby="r-refs">
141 <h2 id="r-refs">refs</h2>141 <h2 id="r-refs">refs</h2>
142-<dl class="kv">142+<dl class="kv" id="refs-kv">
143 <dt>default</dt>143 <dt>default</dt>
144 <dd>main</dd>144 <dd>main</dd>
145 <dt>branches</dt>145 <dt>branches</dt>
@@ -354,7 +354,10 @@
354 <span class="st-ver">ermite devel · </span>354 <span class="st-ver">ermite devel · </span>
355 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>355 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
356 </span>356 </span>
357-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>357+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
358+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
359+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
360+ gap -> ermiteLive.reload()">offline</span>
358 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>361 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span>
359 </footer>362 </footer>
360 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {363 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
M cmd/ermite/testdata/web/summary.html +4 -1
@@ -348,7 +348,10 @@
348348 <span class="st-ver">ermite devel · </span>
349349 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
350350 </span>
351-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
351+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
352+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
353+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
354+ gap -> ermiteLive.reload()">offline</span>
352355 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
353356 </footer>
354357 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
@@ -348,7 +348,10 @@
348 <span class="st-ver">ermite devel · </span>348 <span class="st-ver">ermite devel · </span>
349 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>349 <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a>
350 </span>350 </span>
351-<span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>351+<span class="st-live" data-state="offline" role="status" hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary" hx-swap="none" :text="data.state" :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'" hx-on="htmx:sse:after:connection -> data.state = 'live';
352+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
353+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
354+ gap -> ermiteLive.reload()">offline</span>
352 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>355 <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span>
353 </footer>356 </footer>
354 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {357 <div id="keys" hidden hx-on="k-j from:body -> ((d) => {
M cmd/ermite/web.go +1 -0
@@ -20,6 +20,7 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
2020 srv, err := web.New(web.Config{
2121 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
2222 Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
23+ Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL),
2324 PublicURL: cfg.PublicURL,
2425 SourceURL: cfg.SourceURL,
2526 Version: serverVersion(),
@@ -20,6 +20,7 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) {
20 srv, err := web.New(web.Config{20 srv, err := web.New(web.Config{
21 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),21 Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL),
22 Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),22 Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL),
23+ Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL),
23 PublicURL: cfg.PublicURL,24 PublicURL: cfg.PublicURL,
24 SourceURL: cfg.SourceURL,25 SourceURL: cfg.SourceURL,
25 Version: serverVersion(),26 Version: serverVersion(),
A cmd/ermite/web_events_test.go +388 -0
@@ -0,0 +1,388 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "context"
8+ "html"
9+ "net/http"
10+ "regexp"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/app"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// sseEvent is one event read from /-/events.
23+type sseEvent struct {
24+ id, name, data string
25+ comment bool
26+}
27+
28+// sseStream is a client of /-/events.
29+type sseStream struct {
30+ resp *http.Response
31+ events chan sseEvent
32+ cancel context.CancelFunc
33+}
34+
35+// sse opens /-/events?<query> like the browser does and reads it in the
36+// background. It returns once the server answered, which it does when the
37+// watch is registered: what happens after is not missed.
38+func (h *harness) sse(query, lastID string) *sseStream {
39+ h.t.Helper()
40+ ctx, cancel := context.WithCancel(h.ctx)
41+ h.t.Cleanup(cancel)
42+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?"+query), nil)
43+ require.NoError(h.t, err)
44+ if lastID != "" {
45+ req.Header.Set("Last-Event-ID", lastID)
46+ }
47+ resp, err := http.DefaultClient.Do(req)
48+ require.NoError(h.t, err)
49+ t := h.t
50+ h.t.Cleanup(func() { _ = resp.Body.Close() })
51+ require.Equal(t, http.StatusOK, resp.StatusCode)
52+ s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
53+ go func() {
54+ defer close(s.events)
55+ sc := bufio.NewScanner(resp.Body)
56+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
57+ var cur sseEvent
58+ var data []string
59+ for sc.Scan() {
60+ line := sc.Text()
61+ switch {
62+ case line == "":
63+ if cur.id != "" || cur.name != "" || len(data) > 0 {
64+ cur.data = strings.Join(data, "\n")
65+ s.events <- cur
66+ }
67+ cur, data = sseEvent{}, nil
68+ case strings.HasPrefix(line, ":"):
69+ s.events <- sseEvent{comment: true, data: strings.TrimSpace(line[1:])}
70+ default:
71+ k, v, _ := strings.Cut(line, ":")
72+ v = strings.TrimPrefix(v, " ")
73+ switch k {
74+ case "id":
75+ cur.id = v
76+ case "event":
77+ cur.name = v
78+ case "data":
79+ data = append(data, v)
80+ }
81+ }
82+ }
83+ }()
84+ return s
85+}
86+
87+// next returns the next event (not a comment) or fails after d.
88+func (s *sseStream) next(t *testing.T, d time.Duration) sseEvent {
89+ t.Helper()
90+ timeout := time.After(d)
91+ for {
92+ select {
93+ case ev, ok := <-s.events:
94+ if !ok {
95+ t.Fatal("the stream ended")
96+ }
97+ if ev.comment {
98+ continue
99+ }
100+ return ev
101+ case <-timeout:
102+ t.Fatalf("no event within %v", d)
103+ }
104+ }
105+}
106+
107+// AC5: a push shows up as the row of the repository on the home stream
108+// within a second; creating, hiding and deleting repositories updates or
109+// removes the row.
110+func TestWebEventsHome(t *testing.T) {
111+ h := newHarness(t, harnessOpts{})
112+ h.seed("pub", domain.VisibilityPublic)
113+ s := h.sse("topic=home", "")
114+ assert.Equal(t, "text/event-stream", s.resp.Header.Get("Content-Type"))
115+ assert.Contains(t, s.resp.Header.Get("Cache-Control"), "no-cache")
116+
117+ wt := h.workTree("main", "a.txt")
118+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
119+ start := time.Now()
120+ ev := s.next(t, time.Second)
121+ assert.Less(t, time.Since(start), 2*time.Second)
122+ assert.Equal(t, "home-list", ev.name)
123+ assert.NotEmpty(t, ev.id)
124+ assert.Contains(t, ev.data, `<tr id="repo-pub"`)
125+ assert.Contains(t, ev.data, `new push`)
126+ assert.Contains(t, ev.data, `href="/pub"`)
127+
128+ // A new repository is a row; so is a repository created by a push.
129+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})
130+ require.NoError(t, err)
131+ ev = s.next(t, watchWait)
132+ assert.Equal(t, "home-list", ev.name)
133+ assert.Contains(t, ev.data, `<tr id="repo-fresh"`)
134+ assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
135+ assert.NotContains(t, ev.data, "new push")
136+
137+ // A repository that turns private is removed from the anonymous list.
138+ priv := domain.VisibilityPrivate
139+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), app.UpdateParams{Visibility: &priv})
140+ require.NoError(t, err)
141+ ev = s.next(t, watchWait)
142+ assert.Equal(t, "home-list", ev.name)
143+ assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`)
144+ assert.NotContains(t, ev.data, "fresh one")
145+
146+ // And a deleted one.
147+ require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub")))
148+ ev = s.next(t, watchWait)
149+ assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`)
150+}
151+
152+// AC5: the stream of a repository carries its last push; the refs page its
153+// refs; the commits page the banner with the number of new commits.
154+func TestWebEventsRepoFragments(t *testing.T) {
155+ h := newHarness(t, harnessOpts{})
156+ h.seed("x", domain.VisibilityPublic)
157+ wt := h.workTree("main", "a.txt")
158+ push := func() { h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main") }
159+ push()
160+
161+ summary := h.sse("topic=repo:x&view=summary", "")
162+ refs := h.sse("topic=repo:x&view=refs", "")
163+
164+ _, _, page := h.get("/x/commits/main")
165+ require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner")
166+ m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page)
167+ require.Len(t, m, 2, "the commits page is live")
168+ url := html.UnescapeString(m[1])
169+ require.True(t, strings.HasPrefix(url, "/-/events?"), url)
170+ commits := h.sse(strings.TrimPrefix(url, "/-/events?"), "")
171+
172+ h.writeFile(wt, "b.txt", []byte("b\n"))
173+ h.writeFile(wt, "c.txt", []byte("c\n"))
174+ start := time.Now()
175+ push()
176+
177+ ev := summary.next(t, time.Second)
178+ assert.Less(t, time.Since(start), 2*time.Second)
179+ assert.Equal(t, "repo-last-push", ev.name)
180+ assert.Contains(t, ev.data, `<dd id="last-push-x">`)
181+ assert.Contains(t, ev.data, "now")
182+
183+ ev = refs.next(t, watchWait)
184+ assert.Equal(t, "refs-list", ev.name)
185+ assert.Contains(t, ev.data, `<tbody id="ref-rows" data-rows>`)
186+ assert.Contains(t, ev.data, `>main</a>`)
187+ ev = refs.next(t, watchWait)
188+ assert.Equal(t, "refs-list", ev.name)
189+ assert.Contains(t, ev.data, `<dl class="kv" id="refs-kv">`)
190+
191+ ev = commits.next(t, watchWait)
192+ assert.Equal(t, "commits-head", ev.name)
193+ assert.Contains(t, ev.data, `id="commits-head"`)
194+ assert.Contains(t, ev.data, "2 new commits — reload")
195+ assert.Contains(t, ev.data, `href="/x/commits/main"`)
196+
197+ // One more commit: the count is since the head the page lists.
198+ h.writeFile(wt, "d.txt", []byte("d\n"))
199+ push()
200+ assert.Contains(t, commits.next(t, watchWait).data, "3 new commits — reload")
201+
202+ // A push to another branch is not news for this page.
203+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main:refs/heads/side")
204+ idOnly := commits.next(t, watchWait)
205+ assert.NotEmpty(t, idOnly.id, "the event is acknowledged, so that a reconnection resumes after it")
206+ assert.Empty(t, idOnly.name)
207+ assert.Empty(t, idOnly.data)
208+ h.writeFile(wt, "e.txt", []byte("e\n"))
209+ push()
210+ assert.Contains(t, commits.next(t, watchWait).data, "4 new commits — reload")
211+
212+ // Pages that do not list the head of a branch do not announce anything.
213+ _, _, detached := h.get("/x/commits/main/a.txt")
214+ assert.NotContains(t, detached, `id="commits-head"`)
215+ assert.NotContains(t, detached, "hx-sse:connect")
216+ _, _, old := h.get("/x/commits/main?page=nope")
217+ assert.NotContains(t, old, "hx-sse:connect")
218+}
219+
220+// AC5: Last-Event-ID resumes after that event; an id nobody issued is a gap,
221+// which tells the page to load itself again.
222+func TestWebEventsResumeAndGap(t *testing.T) {
223+ h := newHarness(t, harnessOpts{})
224+ h.seed("pub", domain.VisibilityPublic)
225+ s := h.sse("topic=home", "")
226+ wt := h.workTree("main", "a.txt")
227+ var ids []string
228+ for i := range 3 {
229+ if i > 0 {
230+ h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n"))
231+ }
232+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
233+ ids = append(ids, s.next(t, watchWait).id)
234+ }
235+ s.cancel()
236+
237+ r := h.sse("topic=home", ids[0])
238+ for _, want := range ids[1:] {
239+ ev := r.next(t, watchWait)
240+ assert.Equal(t, want, ev.id)
241+ assert.Equal(t, "home-list", ev.name)
242+ }
243+ h.writeFile(wt, "live.txt", []byte("x\n"))
244+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
245+ live := r.next(t, watchWait)
246+ assert.NotContains(t, ids, live.id)
247+
248+ g := h.sse("topic=home", "not-an-id")
249+ gap := g.next(t, watchWait)
250+ assert.Equal(t, "gap", gap.name)
251+ assert.Empty(t, gap.id)
252+ assert.Equal(t, "home-list", g.next(t, watchWait).name, "the oldest buffered event follows")
253+}
254+
255+// A small ring: the id that fell out of it is a gap.
256+func TestWebEventsGapAfterOverflow(t *testing.T) {
257+ h := newHarness(t, harnessOpts{eventBuffer: 3})
258+ h.seed("pub", domain.VisibilityPublic)
259+ s := h.sse("topic=home", "")
260+ wt := h.workTree("main", "a.txt")
261+ var ids []string
262+ for i := range 6 {
263+ if i > 0 {
264+ h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n"))
265+ }
266+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
267+ ids = append(ids, s.next(t, watchWait).id)
268+ }
269+ r := h.sse("topic=home", ids[1])
270+ assert.Equal(t, "gap", r.next(t, watchWait).name)
271+ assert.Equal(t, ids[3], r.next(t, watchWait).id)
272+}
273+
274+// AC2, AC5: nothing of a private repository reaches the anonymous stream,
275+// and its topic is a missing one.
276+func TestWebEventsPrivate(t *testing.T) {
277+ h := newHarness(t, harnessOpts{})
278+ h.seed("pub", domain.VisibilityPublic)
279+ h.seed("priv", domain.VisibilityPrivate)
280+ s := h.sse("topic=home", "")
281+
282+ missing := h.status("GET", "/-/events?topic=repo:nothing", nil)
283+ assert.Equal(t, http.StatusNotFound, missing)
284+ assert.Equal(t, missing, h.status("GET", "/-/events?topic=repo:priv", nil), "private is indistinguishable from missing")
285+
286+ wt := h.workTree("main", "a.txt")
287+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
288+ desc := "secret change"
289+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), app.UpdateParams{Description: &desc})
290+ require.NoError(t, err)
291+ // Made public later, it does not reveal what happened while it was
292+ // private; the update that makes it public is shown.
293+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
294+ ev := s.next(t, watchWait)
295+ assert.Contains(t, ev.data, `id="repo-pub"`, "the first event is the public push: %s", ev.data)
296+ h.sentinelSSE(t, s, "sentinel")
297+ _, _, body := h.get("/")
298+ assert.Contains(t, body, "repo-pub")
299+ assert.NotContains(t, body, "repo-priv")
300+}
301+
302+// sentinelSSE proves that nothing came since the last event: it creates a
303+// public repository and requires its row to be the next event.
304+func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) {
305+ t.Helper()
306+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
307+ require.NoError(t, err)
308+ ev := s.next(t, watchWait)
309+ assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data)
310+}
311+
312+// A topic is home or repo:<name>; anything else is refused before a stream
313+// starts.
314+func TestWebEventsTopics(t *testing.T) {
315+ h := newHarness(t, harnessOpts{})
316+ h.seed("pub", domain.VisibilityPublic)
317+ for path, want := range map[string]int{
318+ "/-/events": http.StatusBadRequest,
319+ "/-/events?topic=": http.StatusBadRequest,
320+ "/-/events?topic=other": http.StatusBadRequest,
321+ "/-/events?topic=repo:": http.StatusBadRequest,
322+ "/-/events?topic=repo:Bad%20Name": http.StatusNotFound,
323+ "/-/events?topic=repo:..": http.StatusNotFound,
324+ "/-/events?topic=repo:nothing": http.StatusNotFound,
325+ "/-/events?topic=repo:pub.git": http.StatusNotFound,
326+ "/-/events?topic=home,repo:pub": http.StatusBadRequest,
327+ "/-/events?topic=repo:pub&view=zzz": http.StatusOK,
328+ } {
329+ if want == http.StatusOK {
330+ s := h.sse(strings.TrimPrefix(path, "/-/events?"), "")
331+ s.cancel()
332+ continue
333+ }
334+ assert.Equal(t, want, h.status("GET", path, nil), path)
335+ }
336+ // POST is not allowed.
337+ assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil))
338+}
339+
340+// The pages connect to the stream of their topic.
341+func TestWebPagesAreLive(t *testing.T) {
342+ h := newHarness(t, harnessOpts{})
343+ h.webRepo("demo")
344+ for path, want := range map[string]string{
345+ "/": `hx-sse:connect="/-/events?topic=home"`,
346+ "/demo": `hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary"`,
347+ "/demo/refs": `hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs"`,
348+ } {
349+ _, _, body := h.get(path)
350+ assert.Contains(t, body, want, path)
351+ }
352+ _, _, nf := h.get("/nothing")
353+ assert.NotContains(t, nf, "hx-sse:connect")
354+}
355+
356+// The stream ends cleanly when the server stops its event log (the start of
357+// the shutdown): the client sees the end of the body, and the handler is gone
358+// (the test server cannot close with a handler left).
359+func TestWebEventsEndOnShutdown(t *testing.T) {
360+ h := newHarness(t, harnessOpts{})
361+ h.seed("pub", domain.VisibilityPublic)
362+ home := h.sse("topic=home", "")
363+ repo := h.sse("topic=repo:pub", "")
364+
365+ h.forge.EventLog.Close(context.WithoutCancel(h.ctx))
366+ for _, s := range []*sseStream{home, repo} {
367+ select {
368+ case _, ok := <-s.events:
369+ for ok {
370+ _, ok = <-s.events
371+ }
372+ case <-time.After(5 * time.Second):
373+ t.Fatal("the stream did not end")
374+ }
375+ }
376+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond)
377+}
378+
379+// A visitor that goes away frees its watch.
380+func TestWebEventsClientDisconnect(t *testing.T) {
381+ h := newHarness(t, harnessOpts{})
382+ h.seed("pub", domain.VisibilityPublic)
383+ require.Equal(t, 0, h.forge.EventLog.Watchers())
384+ s := h.sse("topic=home", "")
385+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, 5*time.Millisecond)
386+ s.cancel()
387+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond)
388+}
@@ -0,0 +1,388 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package main
4+
5+import (
6+ "bufio"
7+ "context"
8+ "html"
9+ "net/http"
10+ "regexp"
11+ "strings"
12+ "testing"
13+ "time"
14+
15+ "github.com/stretchr/testify/assert"
16+ "github.com/stretchr/testify/require"
17+
18+ "bnema.dev/ermite/internal/app"
19+ "bnema.dev/ermite/internal/domain"
20+)
21+
22+// sseEvent is one event read from /-/events.
23+type sseEvent struct {
24+ id, name, data string
25+ comment bool
26+}
27+
28+// sseStream is a client of /-/events.
29+type sseStream struct {
30+ resp *http.Response
31+ events chan sseEvent
32+ cancel context.CancelFunc
33+}
34+
35+// sse opens /-/events?<query> like the browser does and reads it in the
36+// background. It returns once the server answered, which it does when the
37+// watch is registered: what happens after is not missed.
38+func (h *harness) sse(query, lastID string) *sseStream {
39+ h.t.Helper()
40+ ctx, cancel := context.WithCancel(h.ctx)
41+ h.t.Cleanup(cancel)
42+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url("/-/events?"+query), nil)
43+ require.NoError(h.t, err)
44+ if lastID != "" {
45+ req.Header.Set("Last-Event-ID", lastID)
46+ }
47+ resp, err := http.DefaultClient.Do(req)
48+ require.NoError(h.t, err)
49+ t := h.t
50+ h.t.Cleanup(func() { _ = resp.Body.Close() })
51+ require.Equal(t, http.StatusOK, resp.StatusCode)
52+ s := &sseStream{resp: resp, events: make(chan sseEvent, 256), cancel: cancel}
53+ go func() {
54+ defer close(s.events)
55+ sc := bufio.NewScanner(resp.Body)
56+ sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
57+ var cur sseEvent
58+ var data []string
59+ for sc.Scan() {
60+ line := sc.Text()
61+ switch {
62+ case line == "":
63+ if cur.id != "" || cur.name != "" || len(data) > 0 {
64+ cur.data = strings.Join(data, "\n")
65+ s.events <- cur
66+ }
67+ cur, data = sseEvent{}, nil
68+ case strings.HasPrefix(line, ":"):
69+ s.events <- sseEvent{comment: true, data: strings.TrimSpace(line[1:])}
70+ default:
71+ k, v, _ := strings.Cut(line, ":")
72+ v = strings.TrimPrefix(v, " ")
73+ switch k {
74+ case "id":
75+ cur.id = v
76+ case "event":
77+ cur.name = v
78+ case "data":
79+ data = append(data, v)
80+ }
81+ }
82+ }
83+ }()
84+ return s
85+}
86+
87+// next returns the next event (not a comment) or fails after d.
88+func (s *sseStream) next(t *testing.T, d time.Duration) sseEvent {
89+ t.Helper()
90+ timeout := time.After(d)
91+ for {
92+ select {
93+ case ev, ok := <-s.events:
94+ if !ok {
95+ t.Fatal("the stream ended")
96+ }
97+ if ev.comment {
98+ continue
99+ }
100+ return ev
101+ case <-timeout:
102+ t.Fatalf("no event within %v", d)
103+ }
104+ }
105+}
106+
107+// AC5: a push shows up as the row of the repository on the home stream
108+// within a second; creating, hiding and deleting repositories updates or
109+// removes the row.
110+func TestWebEventsHome(t *testing.T) {
111+ h := newHarness(t, harnessOpts{})
112+ h.seed("pub", domain.VisibilityPublic)
113+ s := h.sse("topic=home", "")
114+ assert.Equal(t, "text/event-stream", s.resp.Header.Get("Content-Type"))
115+ assert.Contains(t, s.resp.Header.Get("Cache-Control"), "no-cache")
116+
117+ wt := h.workTree("main", "a.txt")
118+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
119+ start := time.Now()
120+ ev := s.next(t, time.Second)
121+ assert.Less(t, time.Since(start), 2*time.Second)
122+ assert.Equal(t, "home-list", ev.name)
123+ assert.NotEmpty(t, ev.id)
124+ assert.Contains(t, ev.data, `<tr id="repo-pub"`)
125+ assert.Contains(t, ev.data, `new push`)
126+ assert.Contains(t, ev.data, `href="/pub"`)
127+
128+ // A new repository is a row; so is a repository created by a push.
129+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("fresh"), Description: "a <b>fresh</b> one", Visibility: domain.VisibilityPublic})
130+ require.NoError(t, err)
131+ ev = s.next(t, watchWait)
132+ assert.Equal(t, "home-list", ev.name)
133+ assert.Contains(t, ev.data, `<tr id="repo-fresh"`)
134+ assert.Contains(t, ev.data, "a &lt;b&gt;fresh&lt;/b&gt; one", "text is escaped")
135+ assert.NotContains(t, ev.data, "new push")
136+
137+ // A repository that turns private is removed from the anonymous list.
138+ priv := domain.VisibilityPrivate
139+ _, err = h.forge.Repos.Update(h.ctx, domain.Root(), h.name("fresh"), app.UpdateParams{Visibility: &priv})
140+ require.NoError(t, err)
141+ ev = s.next(t, watchWait)
142+ assert.Equal(t, "home-list", ev.name)
143+ assert.Contains(t, ev.data, `<tr id="repo-fresh" data-gone`)
144+ assert.NotContains(t, ev.data, "fresh one")
145+
146+ // And a deleted one.
147+ require.NoError(t, h.forge.Repos.Delete(h.ctx, domain.Root(), h.name("pub")))
148+ ev = s.next(t, watchWait)
149+ assert.Contains(t, ev.data, `<tr id="repo-pub" data-gone`)
150+}
151+
152+// AC5: the stream of a repository carries its last push; the refs page its
153+// refs; the commits page the banner with the number of new commits.
154+func TestWebEventsRepoFragments(t *testing.T) {
155+ h := newHarness(t, harnessOpts{})
156+ h.seed("x", domain.VisibilityPublic)
157+ wt := h.workTree("main", "a.txt")
158+ push := func() { h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main") }
159+ push()
160+
161+ summary := h.sse("topic=repo:x&view=summary", "")
162+ refs := h.sse("topic=repo:x&view=refs", "")
163+
164+ _, _, page := h.get("/x/commits/main")
165+ require.Contains(t, page, `<div id="commits-head">`, "the slot of the banner")
166+ m := regexp.MustCompile(`hx-sse:connect="([^"]+)"`).FindStringSubmatch(page)
167+ require.Len(t, m, 2, "the commits page is live")
168+ url := html.UnescapeString(m[1])
169+ require.True(t, strings.HasPrefix(url, "/-/events?"), url)
170+ commits := h.sse(strings.TrimPrefix(url, "/-/events?"), "")
171+
172+ h.writeFile(wt, "b.txt", []byte("b\n"))
173+ h.writeFile(wt, "c.txt", []byte("c\n"))
174+ start := time.Now()
175+ push()
176+
177+ ev := summary.next(t, time.Second)
178+ assert.Less(t, time.Since(start), 2*time.Second)
179+ assert.Equal(t, "repo-last-push", ev.name)
180+ assert.Contains(t, ev.data, `<dd id="last-push-x">`)
181+ assert.Contains(t, ev.data, "now")
182+
183+ ev = refs.next(t, watchWait)
184+ assert.Equal(t, "refs-list", ev.name)
185+ assert.Contains(t, ev.data, `<tbody id="ref-rows" data-rows>`)
186+ assert.Contains(t, ev.data, `>main</a>`)
187+ ev = refs.next(t, watchWait)
188+ assert.Equal(t, "refs-list", ev.name)
189+ assert.Contains(t, ev.data, `<dl class="kv" id="refs-kv">`)
190+
191+ ev = commits.next(t, watchWait)
192+ assert.Equal(t, "commits-head", ev.name)
193+ assert.Contains(t, ev.data, `id="commits-head"`)
194+ assert.Contains(t, ev.data, "2 new commits — reload")
195+ assert.Contains(t, ev.data, `href="/x/commits/main"`)
196+
197+ // One more commit: the count is since the head the page lists.
198+ h.writeFile(wt, "d.txt", []byte("d\n"))
199+ push()
200+ assert.Contains(t, commits.next(t, watchWait).data, "3 new commits — reload")
201+
202+ // A push to another branch is not news for this page.
203+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/x.git"), "main:refs/heads/side")
204+ idOnly := commits.next(t, watchWait)
205+ assert.NotEmpty(t, idOnly.id, "the event is acknowledged, so that a reconnection resumes after it")
206+ assert.Empty(t, idOnly.name)
207+ assert.Empty(t, idOnly.data)
208+ h.writeFile(wt, "e.txt", []byte("e\n"))
209+ push()
210+ assert.Contains(t, commits.next(t, watchWait).data, "4 new commits — reload")
211+
212+ // Pages that do not list the head of a branch do not announce anything.
213+ _, _, detached := h.get("/x/commits/main/a.txt")
214+ assert.NotContains(t, detached, `id="commits-head"`)
215+ assert.NotContains(t, detached, "hx-sse:connect")
216+ _, _, old := h.get("/x/commits/main?page=nope")
217+ assert.NotContains(t, old, "hx-sse:connect")
218+}
219+
220+// AC5: Last-Event-ID resumes after that event; an id nobody issued is a gap,
221+// which tells the page to load itself again.
222+func TestWebEventsResumeAndGap(t *testing.T) {
223+ h := newHarness(t, harnessOpts{})
224+ h.seed("pub", domain.VisibilityPublic)
225+ s := h.sse("topic=home", "")
226+ wt := h.workTree("main", "a.txt")
227+ var ids []string
228+ for i := range 3 {
229+ if i > 0 {
230+ h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n"))
231+ }
232+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
233+ ids = append(ids, s.next(t, watchWait).id)
234+ }
235+ s.cancel()
236+
237+ r := h.sse("topic=home", ids[0])
238+ for _, want := range ids[1:] {
239+ ev := r.next(t, watchWait)
240+ assert.Equal(t, want, ev.id)
241+ assert.Equal(t, "home-list", ev.name)
242+ }
243+ h.writeFile(wt, "live.txt", []byte("x\n"))
244+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
245+ live := r.next(t, watchWait)
246+ assert.NotContains(t, ids, live.id)
247+
248+ g := h.sse("topic=home", "not-an-id")
249+ gap := g.next(t, watchWait)
250+ assert.Equal(t, "gap", gap.name)
251+ assert.Empty(t, gap.id)
252+ assert.Equal(t, "home-list", g.next(t, watchWait).name, "the oldest buffered event follows")
253+}
254+
255+// A small ring: the id that fell out of it is a gap.
256+func TestWebEventsGapAfterOverflow(t *testing.T) {
257+ h := newHarness(t, harnessOpts{eventBuffer: 3})
258+ h.seed("pub", domain.VisibilityPublic)
259+ s := h.sse("topic=home", "")
260+ wt := h.workTree("main", "a.txt")
261+ var ids []string
262+ for i := range 6 {
263+ if i > 0 {
264+ h.writeFile(wt, "f"+string(rune('0'+i))+".txt", []byte("x\n"))
265+ }
266+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
267+ ids = append(ids, s.next(t, watchWait).id)
268+ }
269+ r := h.sse("topic=home", ids[1])
270+ assert.Equal(t, "gap", r.next(t, watchWait).name)
271+ assert.Equal(t, ids[3], r.next(t, watchWait).id)
272+}
273+
274+// AC2, AC5: nothing of a private repository reaches the anonymous stream,
275+// and its topic is a missing one.
276+func TestWebEventsPrivate(t *testing.T) {
277+ h := newHarness(t, harnessOpts{})
278+ h.seed("pub", domain.VisibilityPublic)
279+ h.seed("priv", domain.VisibilityPrivate)
280+ s := h.sse("topic=home", "")
281+
282+ missing := h.status("GET", "/-/events?topic=repo:nothing", nil)
283+ assert.Equal(t, http.StatusNotFound, missing)
284+ assert.Equal(t, missing, h.status("GET", "/-/events?topic=repo:priv", nil), "private is indistinguishable from missing")
285+
286+ wt := h.workTree("main", "a.txt")
287+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/priv.git"), "main")
288+ desc := "secret change"
289+ _, err := h.forge.Repos.Update(h.ctx, domain.Root(), h.name("priv"), app.UpdateParams{Description: &desc})
290+ require.NoError(t, err)
291+ // Made public later, it does not reveal what happened while it was
292+ // private; the update that makes it public is shown.
293+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/pub.git"), "main")
294+ ev := s.next(t, watchWait)
295+ assert.Contains(t, ev.data, `id="repo-pub"`, "the first event is the public push: %s", ev.data)
296+ h.sentinelSSE(t, s, "sentinel")
297+ _, _, body := h.get("/")
298+ assert.Contains(t, body, "repo-pub")
299+ assert.NotContains(t, body, "repo-priv")
300+}
301+
302+// sentinelSSE proves that nothing came since the last event: it creates a
303+// public repository and requires its row to be the next event.
304+func (h *harness) sentinelSSE(t *testing.T, s *sseStream, name string) {
305+ t.Helper()
306+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Visibility: domain.VisibilityPublic})
307+ require.NoError(t, err)
308+ ev := s.next(t, watchWait)
309+ assert.Contains(t, ev.data, `id="repo-`+name+`"`, "an event came before the sentinel: %s", ev.data)
310+}
311+
312+// A topic is home or repo:<name>; anything else is refused before a stream
313+// starts.
314+func TestWebEventsTopics(t *testing.T) {
315+ h := newHarness(t, harnessOpts{})
316+ h.seed("pub", domain.VisibilityPublic)
317+ for path, want := range map[string]int{
318+ "/-/events": http.StatusBadRequest,
319+ "/-/events?topic=": http.StatusBadRequest,
320+ "/-/events?topic=other": http.StatusBadRequest,
321+ "/-/events?topic=repo:": http.StatusBadRequest,
322+ "/-/events?topic=repo:Bad%20Name": http.StatusNotFound,
323+ "/-/events?topic=repo:..": http.StatusNotFound,
324+ "/-/events?topic=repo:nothing": http.StatusNotFound,
325+ "/-/events?topic=repo:pub.git": http.StatusNotFound,
326+ "/-/events?topic=home,repo:pub": http.StatusBadRequest,
327+ "/-/events?topic=repo:pub&view=zzz": http.StatusOK,
328+ } {
329+ if want == http.StatusOK {
330+ s := h.sse(strings.TrimPrefix(path, "/-/events?"), "")
331+ s.cancel()
332+ continue
333+ }
334+ assert.Equal(t, want, h.status("GET", path, nil), path)
335+ }
336+ // POST is not allowed.
337+ assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil))
338+}
339+
340+// The pages connect to the stream of their topic.
341+func TestWebPagesAreLive(t *testing.T) {
342+ h := newHarness(t, harnessOpts{})
343+ h.webRepo("demo")
344+ for path, want := range map[string]string{
345+ "/": `hx-sse:connect="/-/events?topic=home"`,
346+ "/demo": `hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=summary"`,
347+ "/demo/refs": `hx-sse:connect="/-/events?topic=repo%3Ademo&amp;view=refs"`,
348+ } {
349+ _, _, body := h.get(path)
350+ assert.Contains(t, body, want, path)
351+ }
352+ _, _, nf := h.get("/nothing")
353+ assert.NotContains(t, nf, "hx-sse:connect")
354+}
355+
356+// The stream ends cleanly when the server stops its event log (the start of
357+// the shutdown): the client sees the end of the body, and the handler is gone
358+// (the test server cannot close with a handler left).
359+func TestWebEventsEndOnShutdown(t *testing.T) {
360+ h := newHarness(t, harnessOpts{})
361+ h.seed("pub", domain.VisibilityPublic)
362+ home := h.sse("topic=home", "")
363+ repo := h.sse("topic=repo:pub", "")
364+
365+ h.forge.EventLog.Close(context.WithoutCancel(h.ctx))
366+ for _, s := range []*sseStream{home, repo} {
367+ select {
368+ case _, ok := <-s.events:
369+ for ok {
370+ _, ok = <-s.events
371+ }
372+ case <-time.After(5 * time.Second):
373+ t.Fatal("the stream did not end")
374+ }
375+ }
376+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond)
377+}
378+
379+// A visitor that goes away frees its watch.
380+func TestWebEventsClientDisconnect(t *testing.T) {
381+ h := newHarness(t, harnessOpts{})
382+ h.seed("pub", domain.VisibilityPublic)
383+ require.Equal(t, 0, h.forge.EventLog.Watchers())
384+ s := h.sse("topic=home", "")
385+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 1 }, 5*time.Second, 5*time.Millisecond)
386+ s.cancel()
387+ require.Eventually(t, func() bool { return h.forge.EventLog.Watchers() == 0 }, 5*time.Second, 5*time.Millisecond)
388+}
M internal/adapters/web/commits.go +7 -0
@@ -40,6 +40,8 @@ type CommitsPage struct {
4040 Open *Link
4141 // Refs are the branch heads and tags among the commits of this page.
4242 Refs []Link
43+ // Live is true when the page announces new commits of its branch.
44+ Live bool
4345 }
4446
4547 // revSel is what the URL of a history page selects.
@@ -126,6 +128,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
126128
127129 l := s.repoLayout(ctx, d, "commits")
128130 s.setRef(&l, sel)
131+ if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {
132+ // The first page of a branch announces the commits pushed to it.
133+ p.Live = true
134+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)
135+ }
129136 l.Crumbs = commitsCrumbs(d, sel)
130137 if sel.path == "" {
131138 l.Title = "commits · " + d.name() + " · " + s.instance
@@ -40,6 +40,8 @@ type CommitsPage struct {
40 Open *Link40 Open *Link
41 // Refs are the branch heads and tags among the commits of this page.41 // Refs are the branch heads and tags among the commits of this page.
42 Refs []Link42 Refs []Link
43+ // Live is true when the page announces new commits of its branch.
44+ Live bool
43 }45 }
44 46
45 // revSel is what the URL of a history page selects.47 // revSel is what the URL of a history page selects.
@@ -126,6 +128,11 @@ func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component,
126 128
127 l := s.repoLayout(ctx, d, "commits")129 l := s.repoLayout(ctx, d, "commits")
128 s.setRef(&l, sel)130 s.setRef(&l, sel)
131+ if d.isBranchHead(sel) && token == "" && sel.path == "" && len(cs) > 0 {
132+ // The first page of a branch announces the commits pushed to it.
133+ p.Live = true
134+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=commits&ref="+url.QueryEscape(sel.name)+"&head="+sel.rev)
135+ }
129 l.Crumbs = commitsCrumbs(d, sel)136 l.Crumbs = commitsCrumbs(d, sel)
130 if sel.path == "" {137 if sel.path == "" {
131 l.Title = "commits · " + d.name() + " · " + s.instance138 l.Title = "commits · " + d.name() + " · " + s.instance
M internal/adapters/web/commits.templ +4 -1
@@ -22,7 +22,10 @@ templ Commits(l Layout, p CommitsPage) {
2222 if p.Path != "" {
2323 <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>
2424 }
25- <div class="days" id="commits-head">
25+ if p.Live {
26+ <div id="commits-head"></div>
27+ }
28+ <div class="days">
2629 for _, g := range p.Days {
2730 <section aria-label={ g.Day }>
2831 <h2 class="rule-h"><span>{ g.Day }</span></h2>
@@ -22,7 +22,10 @@ templ Commits(l Layout, p CommitsPage) {
22 if p.Path != "" {22 if p.Path != "" {
23 <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>23 <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p>
24 }24 }
25- <div class="days" id="commits-head">25+ if p.Live {
26+ <div id="commits-head"></div>
27+ }
28+ <div class="days">
26 for _, g := range p.Days {29 for _, g := range p.Days {
27 <section aria-label={ g.Day }>30 <section aria-label={ g.Day }>
28 <h2 class="rule-h"><span>{ g.Day }</span></h2>31 <h2 class="rule-h"><span>{ g.Day }</span></h2>
M internal/adapters/web/commits_templ.go +53 -43 generated file: collapsed
M internal/adapters/web/components.templ +7 -12
@@ -7,17 +7,7 @@ templ Facts(facts []Fact) {
77 <dl class="kv">
88 for _, f := range facts {
99 <dt>{ f.Key }</dt>
10- <dd
11- if f.ID != "" {
12- id={ f.ID }
13- }
14- >
15- if f.Time != "" {
16- <time datetime={ f.Time }>{ f.Val }</time>
17- } else {
18- { f.Val }
19- }
20- </dd>
10+ @FactValue(f)
2111 }
2212 </dl>
2313 }
@@ -45,7 +35,12 @@ templ CloneBlock(c Clone) {
4535 templ HomeRowView(r HomeRow) {
4636 <tr id={ r.ID } data-cursor?={ r.Cursor }>
4737 <td class="cur" aria-hidden="true"></td>
48- <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td>
38+ <td class="c-name">
39+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
40+ if r.Fresh {
41+ <span class="tagged">● new push</span>
42+ }
43+ </td>
4944 <td class="c-desc dim">{ r.Desc }</td>
5045 <td class="c-when nowrap">
5146 <time
@@ -7,17 +7,7 @@ templ Facts(facts []Fact) {
7 <dl class="kv">7 <dl class="kv">
8 for _, f := range facts {8 for _, f := range facts {
9 <dt>{ f.Key }</dt>9 <dt>{ f.Key }</dt>
10- <dd10+ @FactValue(f)
11- if f.ID != "" {
12- id={ f.ID }
13- }
14- >
15- if f.Time != "" {
16- <time datetime={ f.Time }>{ f.Val }</time>
17- } else {
18- { f.Val }
19- }
20- </dd>
21 }11 }
22 </dl>12 </dl>
23 }13 }
@@ -45,7 +35,12 @@ templ CloneBlock(c Clone) {
45 templ HomeRowView(r HomeRow) {35 templ HomeRowView(r HomeRow) {
46 <tr id={ r.ID } data-cursor?={ r.Cursor }>36 <tr id={ r.ID } data-cursor?={ r.Cursor }>
47 <td class="cur" aria-hidden="true"></td>37 <td class="cur" aria-hidden="true"></td>
48- <td class="c-name"><a href={ templ.SafeURL(r.Href) }>{ r.Name }</a></td>38+ <td class="c-name">
39+ <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a>
40+ if r.Fresh {
41+ <span class="tagged">● new push</span>
42+ }
43+ </td>
49 <td class="c-desc dim">{ r.Desc }</td>44 <td class="c-desc dim">{ r.Desc }</td>
50 <td class="c-when nowrap">45 <td class="c-when nowrap">
51 <time46 <time
M internal/adapters/web/components_templ.go +178 -233 generated file: collapsed
A internal/adapters/web/events.go +334 -0
@@ -0,0 +1,334 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/bnema/zerowrap"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+)
20+
21+// Live updates. A page that can change under the eyes of its reader opens
22+// GET /-/events (the Live component of the status bar). The handler follows
23+// EventService.Watch as the visitor of the request and turns each event into
24+// the HTML fragments the page shows, sent as Server-Sent Events:
25+//
26+// GET /-/events?topic=home
27+// GET /-/events?topic=repo:<name>&view=summary|refs
28+// GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
29+//
30+// topic is the Watch topic. view says which fragments of a repository the
31+// page shows, so that the server renders only those (summary when absent).
32+// On the commits page, ref is the branch it lists and head the commit it
33+// lists first: the banner counts the commits between that head and the new
34+// tip, so it stays right however many pushes came in and however often the
35+// stream reconnected. A Last-Event-ID header resumes the stream
36+// (resume_after).
37+//
38+// Events, each with the id of the Watch event it comes from:
39+//
40+// home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr>
41+// for a repository that is deleted or no longer visible
42+// repo-last-push data: <dd id="last-push-<name>">…</dd>
43+// refs-list data: <tbody id="ref-rows">…</tbody>, and again with
44+// <dl id="refs-kv">…</dl>, the facts of the rail
45+// commits-head data: <div id="commits-head">N new commits — reload</div>
46+// gap data: gap; events were lost, or what the page shows is gone or
47+// changed: the page fetches itself again (ermiteLive in ermite.js)
48+//
49+// The data of an event is one fragment, a top-level element with an id, one
50+// "data:" line per line of HTML; the page swaps the element that has that id.
51+// An event that changes nothing on the page is still sent as an id alone, so
52+// that a reconnection resumes after it.
53+
54+// DefaultHeartbeat is how often an idle stream gets a comment line, so that
55+// proxies keep it open and a dead peer is noticed.
56+const DefaultHeartbeat = 25 * time.Second
57+
58+const (
59+ // startGrace is how long the handler waits after the Watch call answered
60+ // for an early failure (too many watchers) before it commits to 200: the
61+ // Connect protocol reports it in the stream, after the headers.
62+ startGrace = 100 * time.Millisecond
63+ // writeWindow bounds one write to the visitor, whatever the write timeout
64+ // of the server is.
65+ writeWindow = 30 * time.Second
66+ // retryAfter is the delay asked from a visitor refused for lack of room.
67+ retryAfter = 10
68+)
69+
70+// parseTopic reads "home" or "repo:<name>"; repo is "" for home.
71+func parseTopic(topic string) (repo string, ok bool) {
72+ if topic == "home" {
73+ return "", true
74+ }
75+ if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
76+ return name, true
77+ }
78+ return "", false
79+}
80+
81+// events is GET /-/events.
82+func (s *Server) events(w http.ResponseWriter, r *http.Request) {
83+ if s.cfg.Events == nil {
84+ http.NotFound(w, r)
85+ return
86+ }
87+ q := r.URL.Query()
88+ topic := q.Get("topic")
89+ repo, ok := parseTopic(topic)
90+ if !ok {
91+ denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
92+ return
93+ }
94+ ctx, cancel := context.WithCancel(r.Context())
95+ defer cancel()
96+ log := zerowrap.FromCtx(ctx)
97+
98+ if repo != "" {
99+ // A repository the visitor cannot see is a missing one (AC2).
100+ cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
101+ _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo}))
102+ ccancel()
103+ if err != nil {
104+ s.denyEvents(ctx, w, err)
105+ return
106+ }
107+ }
108+
109+ stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{
110+ Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
111+ }))
112+ if err != nil {
113+ s.denyEvents(ctx, w, err)
114+ return
115+ }
116+ // Wait for the answer of the API, which comes once the watch is registered
117+ // (or refused), then read the stream from a goroutine.
118+ _ = stream.ResponseHeader()
119+ msgs := make(chan recvd)
120+ done := make(chan struct{})
121+ go func() {
122+ defer close(done)
123+ defer close(msgs)
124+ for stream.Receive() {
125+ select {
126+ case msgs <- recvd{ev: stream.Msg().GetEvent()}:
127+ case <-ctx.Done():
128+ return
129+ }
130+ }
131+ if err := stream.Err(); err != nil {
132+ select {
133+ case msgs <- recvd{err: err}:
134+ case <-ctx.Done():
135+ }
136+ }
137+ }()
138+ defer func() {
139+ cancel()
140+ _ = stream.Close()
141+ <-done
142+ }()
143+
144+ var first *recvd
145+ grace := time.NewTimer(startGrace)
146+ select {
147+ case m, open := <-msgs:
148+ grace.Stop()
149+ if !open {
150+ return // the server is stopping
151+ }
152+ first = &m
153+ case <-grace.C:
154+ case <-ctx.Done():
155+ grace.Stop()
156+ return
157+ }
158+ if first != nil && first.err != nil {
159+ s.denyEvents(ctx, w, first.err)
160+ return
161+ }
162+
163+ rc := http.NewResponseController(w)
164+ hdr := w.Header()
165+ hdr.Set("Content-Type", "text/event-stream")
166+ hdr.Set("Cache-Control", "no-cache, no-transform")
167+ hdr.Set("X-Accel-Buffering", "no")
168+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
169+ w.WriteHeader(http.StatusOK)
170+ out := &sseWriter{w: w, rc: rc}
171+ if err := out.raw(": connected\n\n"); err != nil {
172+ return
173+ }
174+
175+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head")}
176+ handle := func(m recvd) bool {
177+ if m.err != nil {
178+ if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled {
179+ log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended")
180+ }
181+ return false
182+ }
183+ rctx, rcancel := context.WithTimeout(ctx, requestTimeout)
184+ frags, err := st.render(rctx, m.ev)
185+ rcancel()
186+ if err != nil {
187+ if ctx.Err() != nil {
188+ return false
189+ }
190+ // What the page shows can no longer be told from the events:
191+ // have it fetch itself again.
192+ log.Warn().Err(err).Str("topic", topic).Msg("render live fragment")
193+ frags = gap
194+ }
195+ return out.send(m.ev.GetId(), frags) == nil
196+ }
197+ if first != nil && !handle(*first) {
198+ return
199+ }
200+
201+ beat := s.cfg.Heartbeat
202+ if beat <= 0 {
203+ beat = DefaultHeartbeat
204+ }
205+ tick := time.NewTicker(beat)
206+ defer tick.Stop()
207+ for {
208+ select {
209+ case m, open := <-msgs:
210+ if !open || !handle(m) {
211+ return
212+ }
213+ case <-tick.C:
214+ if out.raw(": heartbeat\n\n") != nil {
215+ return
216+ }
217+ case <-ctx.Done():
218+ return
219+ }
220+ }
221+}
222+
223+// recvd is a message of the Watch stream, or how it failed.
224+type recvd struct {
225+ ev *pb.Event
226+ err error
227+}
228+
229+// denyStream answers a request that gets no stream.
230+func denyStream(w http.ResponseWriter, status int, msg string) {
231+ w.Header().Set("Cache-Control", "no-store")
232+ http.Error(w, msg, status)
233+}
234+
235+// denyEvents maps an error of the API, before the stream started, to a
236+// status. Not found and invalid are 404 whatever the reason: a private
237+// repository is a missing one.
238+func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) {
239+ switch code := connect.CodeOf(err); {
240+ case code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
241+ denyStream(w, http.StatusNotFound, "not found")
242+ case code == connect.CodeResourceExhausted:
243+ w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
244+ denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later")
245+ case code == connect.CodeUnauthenticated:
246+ denyStream(w, http.StatusUnauthorized, "unauthorized")
247+ case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
248+ // The visitor is gone.
249+ default:
250+ log := zerowrap.FromCtx(ctx)
251+ log.Error().Err(err).Msg("open event stream")
252+ denyStream(w, http.StatusInternalServerError, "internal error")
253+ }
254+}
255+
256+// fragment is one SSE event to send: the name and the HTML of the fragment.
257+type fragment struct{ event, html string }
258+
259+// sseWriter writes events to a visitor, flushing each, under a deadline of
260+// its own so that the write timeout of the server cannot end the stream.
261+type sseWriter struct {
262+ w http.ResponseWriter
263+ rc *http.ResponseController
264+}
265+
266+func (o *sseWriter) raw(s string) error {
267+ _ = o.rc.SetWriteDeadline(time.Now().Add(writeWindow))
268+ if _, err := io.WriteString(o.w, s); err != nil {
269+ return err
270+ }
271+ return o.rc.Flush()
272+}
273+
274+// send writes the fragments of one Watch event under its id. An event that
275+// has no fragment still carries its id, so that a reconnection resumes after
276+// it.
277+func (o *sseWriter) send(id string, frags []fragment) error {
278+ if len(frags) == 0 {
279+ if id == "" {
280+ return nil
281+ }
282+ return o.raw(formatEvent(id, "", "", false))
283+ }
284+ for _, f := range frags {
285+ if err := o.raw(formatEvent(id, f.event, f.html, true)); err != nil {
286+ return err
287+ }
288+ }
289+ return nil
290+}
291+
292+// formatEvent is one SSE event. Each line of data is a "data:" field, and a
293+// browser joins them back with newlines.
294+func formatEvent(id, event, data string, hasData bool) string {
295+ var b strings.Builder
296+ if event != "" {
297+ b.WriteString("event: " + event + "\n")
298+ }
299+ if id != "" {
300+ b.WriteString("id: " + id + "\n")
301+ }
302+ if hasData {
303+ data = strings.ReplaceAll(strings.ReplaceAll(data, "\r\n", "\n"), "\r", "\n")
304+ for line := range strings.SplitSeq(data, "\n") {
305+ b.WriteString("data: " + line + "\n")
306+ }
307+ }
308+ b.WriteString("\n")
309+ return b.String()
310+}
311+
312+// liveURL is the stream a page follows: the topic and, for a repository
313+// page, what it shows (see the comment on events).
314+func liveURL(topic, query string) string {
315+ u := "/-/events?topic=" + url.QueryEscape(topic)
316+ if query != "" {
317+ u += "&" + query
318+ }
319+ return u
320+}
321+
322+// isBranchHead reports whether sel is a branch at its tip, as opposed to a
323+// tag or a commit id.
324+func (d *repoData) isBranchHead(sel revSel) bool {
325+ if sel.detached || !isFullID(sel.rev) {
326+ return false
327+ }
328+ for _, b := range d.branches {
329+ if b.GetShortName() == sel.name && b.GetCommitId() == sel.rev {
330+ return true
331+ }
332+ }
333+ return false
334+}
@@ -0,0 +1,334 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "context"
7+ "errors"
8+ "io"
9+ "net/http"
10+ "net/url"
11+ "strconv"
12+ "strings"
13+ "time"
14+
15+ "connectrpc.com/connect"
16+ "github.com/bnema/zerowrap"
17+
18+ pb "bnema.dev/ermite-api/gen/ermite"
19+)
20+
21+// Live updates. A page that can change under the eyes of its reader opens
22+// GET /-/events (the Live component of the status bar). The handler follows
23+// EventService.Watch as the visitor of the request and turns each event into
24+// the HTML fragments the page shows, sent as Server-Sent Events:
25+//
26+// GET /-/events?topic=home
27+// GET /-/events?topic=repo:<name>&view=summary|refs
28+// GET /-/events?topic=repo:<name>&view=commits&ref=<branch>&head=<sha>
29+//
30+// topic is the Watch topic. view says which fragments of a repository the
31+// page shows, so that the server renders only those (summary when absent).
32+// On the commits page, ref is the branch it lists and head the commit it
33+// lists first: the banner counts the commits between that head and the new
34+// tip, so it stays right however many pushes came in and however often the
35+// stream reconnected. A Last-Event-ID header resumes the stream
36+// (resume_after).
37+//
38+// Events, each with the id of the Watch event it comes from:
39+//
40+// home-list data: <tr id="repo-<name>">…</tr>, or <tr id="repo-<name>" data-gone></tr>
41+// for a repository that is deleted or no longer visible
42+// repo-last-push data: <dd id="last-push-<name>">…</dd>
43+// refs-list data: <tbody id="ref-rows">…</tbody>, and again with
44+// <dl id="refs-kv">…</dl>, the facts of the rail
45+// commits-head data: <div id="commits-head">N new commits — reload</div>
46+// gap data: gap; events were lost, or what the page shows is gone or
47+// changed: the page fetches itself again (ermiteLive in ermite.js)
48+//
49+// The data of an event is one fragment, a top-level element with an id, one
50+// "data:" line per line of HTML; the page swaps the element that has that id.
51+// An event that changes nothing on the page is still sent as an id alone, so
52+// that a reconnection resumes after it.
53+
54+// DefaultHeartbeat is how often an idle stream gets a comment line, so that
55+// proxies keep it open and a dead peer is noticed.
56+const DefaultHeartbeat = 25 * time.Second
57+
58+const (
59+ // startGrace is how long the handler waits after the Watch call answered
60+ // for an early failure (too many watchers) before it commits to 200: the
61+ // Connect protocol reports it in the stream, after the headers.
62+ startGrace = 100 * time.Millisecond
63+ // writeWindow bounds one write to the visitor, whatever the write timeout
64+ // of the server is.
65+ writeWindow = 30 * time.Second
66+ // retryAfter is the delay asked from a visitor refused for lack of room.
67+ retryAfter = 10
68+)
69+
70+// parseTopic reads "home" or "repo:<name>"; repo is "" for home.
71+func parseTopic(topic string) (repo string, ok bool) {
72+ if topic == "home" {
73+ return "", true
74+ }
75+ if name, found := strings.CutPrefix(topic, "repo:"); found && name != "" {
76+ return name, true
77+ }
78+ return "", false
79+}
80+
81+// events is GET /-/events.
82+func (s *Server) events(w http.ResponseWriter, r *http.Request) {
83+ if s.cfg.Events == nil {
84+ http.NotFound(w, r)
85+ return
86+ }
87+ q := r.URL.Query()
88+ topic := q.Get("topic")
89+ repo, ok := parseTopic(topic)
90+ if !ok {
91+ denyStream(w, http.StatusBadRequest, "topic must be home or repo:<name>")
92+ return
93+ }
94+ ctx, cancel := context.WithCancel(r.Context())
95+ defer cancel()
96+ log := zerowrap.FromCtx(ctx)
97+
98+ if repo != "" {
99+ // A repository the visitor cannot see is a missing one (AC2).
100+ cctx, ccancel := context.WithTimeout(ctx, requestTimeout)
101+ _, err := s.cfg.Repos.GetRepo(cctx, connect.NewRequest(&pb.GetRepoRequest{Repo: repo}))
102+ ccancel()
103+ if err != nil {
104+ s.denyEvents(ctx, w, err)
105+ return
106+ }
107+ }
108+
109+ stream, err := s.cfg.Events.Watch(ctx, connect.NewRequest(&pb.WatchRequest{
110+ Topics: []string{topic}, ResumeAfter: r.Header.Get("Last-Event-ID"),
111+ }))
112+ if err != nil {
113+ s.denyEvents(ctx, w, err)
114+ return
115+ }
116+ // Wait for the answer of the API, which comes once the watch is registered
117+ // (or refused), then read the stream from a goroutine.
118+ _ = stream.ResponseHeader()
119+ msgs := make(chan recvd)
120+ done := make(chan struct{})
121+ go func() {
122+ defer close(done)
123+ defer close(msgs)
124+ for stream.Receive() {
125+ select {
126+ case msgs <- recvd{ev: stream.Msg().GetEvent()}:
127+ case <-ctx.Done():
128+ return
129+ }
130+ }
131+ if err := stream.Err(); err != nil {
132+ select {
133+ case msgs <- recvd{err: err}:
134+ case <-ctx.Done():
135+ }
136+ }
137+ }()
138+ defer func() {
139+ cancel()
140+ _ = stream.Close()
141+ <-done
142+ }()
143+
144+ var first *recvd
145+ grace := time.NewTimer(startGrace)
146+ select {
147+ case m, open := <-msgs:
148+ grace.Stop()
149+ if !open {
150+ return // the server is stopping
151+ }
152+ first = &m
153+ case <-grace.C:
154+ case <-ctx.Done():
155+ grace.Stop()
156+ return
157+ }
158+ if first != nil && first.err != nil {
159+ s.denyEvents(ctx, w, first.err)
160+ return
161+ }
162+
163+ rc := http.NewResponseController(w)
164+ hdr := w.Header()
165+ hdr.Set("Content-Type", "text/event-stream")
166+ hdr.Set("Cache-Control", "no-cache, no-transform")
167+ hdr.Set("X-Accel-Buffering", "no")
168+ _ = rc.SetWriteDeadline(time.Now().Add(writeWindow))
169+ w.WriteHeader(http.StatusOK)
170+ out := &sseWriter{w: w, rc: rc}
171+ if err := out.raw(": connected\n\n"); err != nil {
172+ return
173+ }
174+
175+ st := &liveStream{s: s, repo: repo, view: q.Get("view"), ref: q.Get("ref"), head: q.Get("head")}
176+ handle := func(m recvd) bool {
177+ if m.err != nil {
178+ if ctx.Err() == nil && connect.CodeOf(m.err) != connect.CodeCanceled {
179+ log.Debug().Err(m.err).Str("topic", topic).Msg("event stream ended")
180+ }
181+ return false
182+ }
183+ rctx, rcancel := context.WithTimeout(ctx, requestTimeout)
184+ frags, err := st.render(rctx, m.ev)
185+ rcancel()
186+ if err != nil {
187+ if ctx.Err() != nil {
188+ return false
189+ }
190+ // What the page shows can no longer be told from the events:
191+ // have it fetch itself again.
192+ log.Warn().Err(err).Str("topic", topic).Msg("render live fragment")
193+ frags = gap
194+ }
195+ return out.send(m.ev.GetId(), frags) == nil
196+ }
197+ if first != nil && !handle(*first) {
198+ return
199+ }
200+
201+ beat := s.cfg.Heartbeat
202+ if beat <= 0 {
203+ beat = DefaultHeartbeat
204+ }
205+ tick := time.NewTicker(beat)
206+ defer tick.Stop()
207+ for {
208+ select {
209+ case m, open := <-msgs:
210+ if !open || !handle(m) {
211+ return
212+ }
213+ case <-tick.C:
214+ if out.raw(": heartbeat\n\n") != nil {
215+ return
216+ }
217+ case <-ctx.Done():
218+ return
219+ }
220+ }
221+}
222+
223+// recvd is a message of the Watch stream, or how it failed.
224+type recvd struct {
225+ ev *pb.Event
226+ err error
227+}
228+
229+// denyStream answers a request that gets no stream.
230+func denyStream(w http.ResponseWriter, status int, msg string) {
231+ w.Header().Set("Cache-Control", "no-store")
232+ http.Error(w, msg, status)
233+}
234+
235+// denyEvents maps an error of the API, before the stream started, to a
236+// status. Not found and invalid are 404 whatever the reason: a private
237+// repository is a missing one.
238+func (s *Server) denyEvents(ctx context.Context, w http.ResponseWriter, err error) {
239+ switch code := connect.CodeOf(err); {
240+ case code == connect.CodeNotFound, code == connect.CodeInvalidArgument:
241+ denyStream(w, http.StatusNotFound, "not found")
242+ case code == connect.CodeResourceExhausted:
243+ w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
244+ denyStream(w, http.StatusServiceUnavailable, "too many event streams, retry later")
245+ case code == connect.CodeUnauthenticated:
246+ denyStream(w, http.StatusUnauthorized, "unauthorized")
247+ case errors.Is(err, context.Canceled), code == connect.CodeCanceled:
248+ // The visitor is gone.
249+ default:
250+ log := zerowrap.FromCtx(ctx)
251+ log.Error().Err(err).Msg("open event stream")
252+ denyStream(w, http.StatusInternalServerError, "internal error")
253+ }
254+}
255+
256+// fragment is one SSE event to send: the name and the HTML of the fragment.
257+type fragment struct{ event, html string }
258+
259+// sseWriter writes events to a visitor, flushing each, under a deadline of
260+// its own so that the write timeout of the server cannot end the stream.
261+type sseWriter struct {
262+ w http.ResponseWriter
263+ rc *http.ResponseController
264+}
265+
266+func (o *sseWriter) raw(s string) error {
267+ _ = o.rc.SetWriteDeadline(time.Now().Add(writeWindow))
268+ if _, err := io.WriteString(o.w, s); err != nil {
269+ return err
270+ }
271+ return o.rc.Flush()
272+}
273+
274+// send writes the fragments of one Watch event under its id. An event that
275+// has no fragment still carries its id, so that a reconnection resumes after
276+// it.
277+func (o *sseWriter) send(id string, frags []fragment) error {
278+ if len(frags) == 0 {
279+ if id == "" {
280+ return nil
281+ }
282+ return o.raw(formatEvent(id, "", "", false))
283+ }
284+ for _, f := range frags {
285+ if err := o.raw(formatEvent(id, f.event, f.html, true)); err != nil {
286+ return err
287+ }
288+ }
289+ return nil
290+}
291+
292+// formatEvent is one SSE event. Each line of data is a "data:" field, and a
293+// browser joins them back with newlines.
294+func formatEvent(id, event, data string, hasData bool) string {
295+ var b strings.Builder
296+ if event != "" {
297+ b.WriteString("event: " + event + "\n")
298+ }
299+ if id != "" {
300+ b.WriteString("id: " + id + "\n")
301+ }
302+ if hasData {
303+ data = strings.ReplaceAll(strings.ReplaceAll(data, "\r\n", "\n"), "\r", "\n")
304+ for line := range strings.SplitSeq(data, "\n") {
305+ b.WriteString("data: " + line + "\n")
306+ }
307+ }
308+ b.WriteString("\n")
309+ return b.String()
310+}
311+
312+// liveURL is the stream a page follows: the topic and, for a repository
313+// page, what it shows (see the comment on events).
314+func liveURL(topic, query string) string {
315+ u := "/-/events?topic=" + url.QueryEscape(topic)
316+ if query != "" {
317+ u += "&" + query
318+ }
319+ return u
320+}
321+
322+// isBranchHead reports whether sel is a branch at its tip, as opposed to a
323+// tag or a commit id.
324+func (d *repoData) isBranchHead(sel revSel) bool {
325+ if sel.detached || !isFullID(sel.rev) {
326+ return false
327+ }
328+ for _, b := range d.branches {
329+ if b.GetShortName() == sel.name && b.GetCommitId() == sel.rev {
330+ return true
331+ }
332+ }
333+ return false
334+}
A internal/adapters/web/events_test.go +234 -0
@@ -0,0 +1,234 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "sync/atomic"
13+ "testing"
14+ "time"
15+
16+ "connectrpc.com/connect"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+)
23+
24+func TestFormatEvent(t *testing.T) {
25+ assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n",
26+ formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line")
27+ assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true))
28+ assert.Equal(t, "id: b-2\n\n", formatEvent("b-2", "", "", false), "an id alone")
29+ assert.Equal(t, "event: e\ndata: a\ndata: b\ndata: c\n\n", formatEvent("", "e", "a\r\nb\rc", true), "no line break escapes the field")
30+ assert.Equal(t, "event: e\ndata: \n\n", formatEvent("", "e", "", true))
31+}
32+
33+func TestParseTopic(t *testing.T) {
34+ for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {
35+ repo, ok := parseTopic(in)
36+ assert.True(t, ok, in)
37+ assert.Equal(t, want, repo, in)
38+ }
39+ for _, in := range []string{"", "repo:", "other", "home,repo:x", "Repo:x", "repo"} {
40+ _, ok := parseTopic(in)
41+ assert.False(t, ok, in)
42+ }
43+}
44+
45+func TestLiveURL(t *testing.T) {
46+ assert.Equal(t, "/-/events?topic=home", liveURL("home", ""))
47+ assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs"))
48+}
49+
50+func TestNewCommitsText(t *testing.T) {
51+ assert.Equal(t, "1 new commit — reload", newCommits("1", 1))
52+ assert.Equal(t, "3 new commits — reload", newCommits("3", 3))
53+ assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2))
54+}
55+
56+// fakeEvents is an EventService whose behaviour a test sets.
57+type fakeEvents struct {
58+ ermiteconnect.UnimplementedEventServiceHandler
59+ err error
60+ events []*pb.Event
61+ opened atomic.Int32
62+ closed atomic.Int32
63+ resume atomic.Value
64+ release chan struct{}
65+}
66+
67+func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error {
68+ f.opened.Add(1)
69+ defer f.closed.Add(1)
70+ f.resume.Store(req.Msg.GetResumeAfter())
71+ if f.err != nil {
72+ return f.err
73+ }
74+ _ = st.Send(nil)
75+ for _, ev := range f.events {
76+ if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil {
77+ return err
78+ }
79+ }
80+ select {
81+ case <-ctx.Done():
82+ case <-f.release:
83+ }
84+ return nil
85+}
86+
87+// repoOK answers every GetRepo with a public repository.
88+type repoOK struct {
89+ ermiteconnect.UnimplementedRepoServiceHandler
90+}
91+
92+func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
93+ return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil
94+}
95+
96+// eventsServer serves the web on a fake API, with a write timeout shorter
97+// than the heartbeat, like a server that sets one.
98+func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server {
99+ t.Helper()
100+ api := http.NewServeMux()
101+ api.Handle(ermiteconnect.NewEventServiceHandler(f))
102+ api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{}))
103+ backend := httptest.NewServer(api)
104+ t.Cleanup(backend.Close)
105+
106+ srv, err := New(Config{
107+ Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL),
108+ Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL),
109+ Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL),
110+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
111+ Heartbeat: beat,
112+ })
113+ require.NoError(t, err)
114+ ts := httptest.NewUnstartedServer(srv)
115+ ts.Config.WriteTimeout = writeTimeout
116+ ts.Start()
117+ t.Cleanup(ts.Close)
118+ return ts
119+}
120+
121+func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string) (*http.Response, context.CancelFunc) {
122+ t.Helper()
123+ ctx, cancel := context.WithCancel(context.Background())
124+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/-/events?"+query, nil)
125+ require.NoError(t, err)
126+ for k, v := range hdr {
127+ req.Header.Set(k, v)
128+ }
129+ resp, err := ts.Client().Do(req)
130+ require.NoError(t, err)
131+ t.Cleanup(func() { cancel(); _ = resp.Body.Close() })
132+ return resp, cancel
133+}
134+
135+// An idle stream gets a comment every heartbeat, and the write timeout of the
136+// server does not end it.
137+func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) {
138+ f := &fakeEvents{release: make(chan struct{})}
139+ ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond)
140+ resp, cancel := open(t, ts, "topic=home", nil)
141+ require.Equal(t, http.StatusOK, resp.StatusCode)
142+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
143+
144+ sc := bufio.NewScanner(resp.Body)
145+ beats := 0
146+ deadline := time.AfterFunc(5*time.Second, cancel)
147+ defer deadline.Stop()
148+ start := time.Now()
149+ for sc.Scan() && beats < 8 {
150+ if strings.HasPrefix(sc.Text(), ": heartbeat") {
151+ beats++
152+ }
153+ }
154+ assert.Equal(t, 8, beats)
155+ assert.Greater(t, time.Since(start), 250*time.Millisecond, "the stream outlived the 100 ms write timeout")
156+}
157+
158+// The id of the event, its name and the lines of its data come through; the
159+// Last-Event-ID header is the resume point; a Gap becomes a gap event.
160+func TestEventsStreamsEventsAndResumes(t *testing.T) {
161+ f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{
162+ {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}},
163+ {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}},
164+ }}
165+ ts := eventsServer(t, f, time.Hour, 0)
166+ resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"})
167+ require.Equal(t, http.StatusOK, resp.StatusCode)
168+ sc := bufio.NewScanner(resp.Body)
169+ var lines []string
170+ for sc.Scan() {
171+ lines = append(lines, sc.Text())
172+ if sc.Text() == "data: gap" {
173+ break
174+ }
175+ }
176+ assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id")
177+ assert.Contains(t, lines, "event: gap")
178+ assert.Equal(t, "b-6", f.resume.Load())
179+}
180+
181+// Too many watchers is a 503 the browser can retry, not a stream.
182+func TestEventsResourceExhausted(t *testing.T) {
183+ f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))}
184+ ts := eventsServer(t, f, time.Hour, 0)
185+ resp, _ := open(t, ts, "topic=home", nil)
186+ assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
187+ assert.NotEmpty(t, resp.Header.Get("Retry-After"))
188+ assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream")
189+
190+ f.err = connect.NewError(connect.CodeNotFound, errors.New("x"))
191+ resp, _ = open(t, ts, "topic=home", nil)
192+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
193+ f.err = errors.New("boom")
194+ resp, _ = open(t, ts, "topic=home", nil)
195+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
196+}
197+
198+// The handler returns, and the watch it opened ends, when the visitor goes
199+// away or the API ends the stream.
200+func TestEventsNoLeak(t *testing.T) {
201+ f := &fakeEvents{release: make(chan struct{})}
202+ ts := eventsServer(t, f, time.Hour, 0)
203+
204+ resp, cancel := open(t, ts, "topic=home", nil)
205+ require.Equal(t, http.StatusOK, resp.StatusCode)
206+ require.Eventually(t, func() bool { return f.opened.Load() == 1 }, 5*time.Second, time.Millisecond)
207+ cancel()
208+ require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the visitor left")
209+
210+ resp, _ = open(t, ts, "topic=home", nil)
211+ require.Equal(t, http.StatusOK, resp.StatusCode)
212+ require.Eventually(t, func() bool { return f.opened.Load() == 2 }, 5*time.Second, time.Millisecond)
213+ close(f.release) // the API ends the stream (shutdown)
214+ done := make(chan struct{})
215+ go func() { _, _ = bufio.NewReader(resp.Body).ReadString(0); close(done) }()
216+ select {
217+ case <-done:
218+ case <-time.After(5 * time.Second):
219+ t.Fatal("the stream did not end with the API's")
220+ }
221+ require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond)
222+}
223+
224+func TestEventsOffWithoutClient(t *testing.T) {
225+ srv, err := New(Config{
226+ Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"),
227+ Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"),
228+ PublicURL: "https://forge.example",
229+ })
230+ require.NoError(t, err)
231+ rec := httptest.NewRecorder()
232+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil))
233+ assert.Equal(t, http.StatusNotFound, rec.Code)
234+}
@@ -0,0 +1,234 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
6+ "bufio"
7+ "context"
8+ "errors"
9+ "net/http"
10+ "net/http/httptest"
11+ "strings"
12+ "sync/atomic"
13+ "testing"
14+ "time"
15+
16+ "connectrpc.com/connect"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
19+
20+ pb "bnema.dev/ermite-api/gen/ermite"
21+ "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
22+)
23+
24+func TestFormatEvent(t *testing.T) {
25+ assert.Equal(t, "event: home-list\nid: b-1\ndata: <tr id=\"x\">\ndata: <td>1</td>\ndata: </tr>\n\n",
26+ formatEvent("b-1", "home-list", "<tr id=\"x\">\n <td>1</td>\n</tr>", true), "one data line per line")
27+ assert.Equal(t, "event: gap\ndata: gap\n\n", formatEvent("", "gap", "gap", true))
28+ assert.Equal(t, "id: b-2\n\n", formatEvent("b-2", "", "", false), "an id alone")
29+ assert.Equal(t, "event: e\ndata: a\ndata: b\ndata: c\n\n", formatEvent("", "e", "a\r\nb\rc", true), "no line break escapes the field")
30+ assert.Equal(t, "event: e\ndata: \n\n", formatEvent("", "e", "", true))
31+}
32+
33+func TestParseTopic(t *testing.T) {
34+ for in, want := range map[string]string{"home": "", "repo:x": "x", "repo:a.b-c": "a.b-c"} {
35+ repo, ok := parseTopic(in)
36+ assert.True(t, ok, in)
37+ assert.Equal(t, want, repo, in)
38+ }
39+ for _, in := range []string{"", "repo:", "other", "home,repo:x", "Repo:x", "repo"} {
40+ _, ok := parseTopic(in)
41+ assert.False(t, ok, in)
42+ }
43+}
44+
45+func TestLiveURL(t *testing.T) {
46+ assert.Equal(t, "/-/events?topic=home", liveURL("home", ""))
47+ assert.Equal(t, "/-/events?topic=repo%3Ax&view=refs", liveURL("repo:x", "view=refs"))
48+}
49+
50+func TestNewCommitsText(t *testing.T) {
51+ assert.Equal(t, "1 new commit — reload", newCommits("1", 1))
52+ assert.Equal(t, "3 new commits — reload", newCommits("3", 3))
53+ assert.Equal(t, "100+ new commits — reload", newCommits("100+", 2))
54+}
55+
56+// fakeEvents is an EventService whose behaviour a test sets.
57+type fakeEvents struct {
58+ ermiteconnect.UnimplementedEventServiceHandler
59+ err error
60+ events []*pb.Event
61+ opened atomic.Int32
62+ closed atomic.Int32
63+ resume atomic.Value
64+ release chan struct{}
65+}
66+
67+func (f *fakeEvents) Watch(ctx context.Context, req *connect.Request[pb.WatchRequest], st *connect.ServerStream[pb.WatchResponse]) error {
68+ f.opened.Add(1)
69+ defer f.closed.Add(1)
70+ f.resume.Store(req.Msg.GetResumeAfter())
71+ if f.err != nil {
72+ return f.err
73+ }
74+ _ = st.Send(nil)
75+ for _, ev := range f.events {
76+ if err := st.Send(&pb.WatchResponse{Event: ev}); err != nil {
77+ return err
78+ }
79+ }
80+ select {
81+ case <-ctx.Done():
82+ case <-f.release:
83+ }
84+ return nil
85+}
86+
87+// repoOK answers every GetRepo with a public repository.
88+type repoOK struct {
89+ ermiteconnect.UnimplementedRepoServiceHandler
90+}
91+
92+func (repoOK) GetRepo(_ context.Context, req *connect.Request[pb.GetRepoRequest]) (*connect.Response[pb.GetRepoResponse], error) {
93+ return connect.NewResponse(&pb.GetRepoResponse{Repo: &pb.Repo{Name: req.Msg.GetRepo(), DefaultBranch: "main"}}), nil
94+}
95+
96+// eventsServer serves the web on a fake API, with a write timeout shorter
97+// than the heartbeat, like a server that sets one.
98+func eventsServer(t *testing.T, f *fakeEvents, beat time.Duration, writeTimeout time.Duration) *httptest.Server {
99+ t.Helper()
100+ api := http.NewServeMux()
101+ api.Handle(ermiteconnect.NewEventServiceHandler(f))
102+ api.Handle(ermiteconnect.NewRepoServiceHandler(repoOK{}))
103+ backend := httptest.NewServer(api)
104+ t.Cleanup(backend.Close)
105+
106+ srv, err := New(Config{
107+ Repos: ermiteconnect.NewRepoServiceClient(backend.Client(), backend.URL),
108+ Git: ermiteconnect.NewGitServiceClient(backend.Client(), backend.URL),
109+ Events: ermiteconnect.NewEventServiceClient(backend.Client(), backend.URL),
110+ PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1",
111+ Heartbeat: beat,
112+ })
113+ require.NoError(t, err)
114+ ts := httptest.NewUnstartedServer(srv)
115+ ts.Config.WriteTimeout = writeTimeout
116+ ts.Start()
117+ t.Cleanup(ts.Close)
118+ return ts
119+}
120+
121+func open(t *testing.T, ts *httptest.Server, query string, hdr map[string]string) (*http.Response, context.CancelFunc) {
122+ t.Helper()
123+ ctx, cancel := context.WithCancel(context.Background())
124+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, ts.URL+"/-/events?"+query, nil)
125+ require.NoError(t, err)
126+ for k, v := range hdr {
127+ req.Header.Set(k, v)
128+ }
129+ resp, err := ts.Client().Do(req)
130+ require.NoError(t, err)
131+ t.Cleanup(func() { cancel(); _ = resp.Body.Close() })
132+ return resp, cancel
133+}
134+
135+// An idle stream gets a comment every heartbeat, and the write timeout of the
136+// server does not end it.
137+func TestEventsHeartbeatSurvivesWriteTimeout(t *testing.T) {
138+ f := &fakeEvents{release: make(chan struct{})}
139+ ts := eventsServer(t, f, 40*time.Millisecond, 100*time.Millisecond)
140+ resp, cancel := open(t, ts, "topic=home", nil)
141+ require.Equal(t, http.StatusOK, resp.StatusCode)
142+ assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type"))
143+
144+ sc := bufio.NewScanner(resp.Body)
145+ beats := 0
146+ deadline := time.AfterFunc(5*time.Second, cancel)
147+ defer deadline.Stop()
148+ start := time.Now()
149+ for sc.Scan() && beats < 8 {
150+ if strings.HasPrefix(sc.Text(), ": heartbeat") {
151+ beats++
152+ }
153+ }
154+ assert.Equal(t, 8, beats)
155+ assert.Greater(t, time.Since(start), 250*time.Millisecond, "the stream outlived the 100 ms write timeout")
156+}
157+
158+// The id of the event, its name and the lines of its data come through; the
159+// Last-Event-ID header is the resume point; a Gap becomes a gap event.
160+func TestEventsStreamsEventsAndResumes(t *testing.T) {
161+ f := &fakeEvents{release: make(chan struct{}), events: []*pb.Event{
162+ {Id: "b-7", Kind: &pb.Event_Pushed{Pushed: &pb.Pushed{Repo: "x"}}},
163+ {Kind: &pb.Event_Gap{Gap: &pb.Gap{}}},
164+ }}
165+ ts := eventsServer(t, f, time.Hour, 0)
166+ resp, _ := open(t, ts, "topic=repo:x&view=summary", map[string]string{"Last-Event-ID": "b-6"})
167+ require.Equal(t, http.StatusOK, resp.StatusCode)
168+ sc := bufio.NewScanner(resp.Body)
169+ var lines []string
170+ for sc.Scan() {
171+ lines = append(lines, sc.Text())
172+ if sc.Text() == "data: gap" {
173+ break
174+ }
175+ }
176+ assert.Contains(t, lines, "id: b-7", "an event with nothing to show keeps its id")
177+ assert.Contains(t, lines, "event: gap")
178+ assert.Equal(t, "b-6", f.resume.Load())
179+}
180+
181+// Too many watchers is a 503 the browser can retry, not a stream.
182+func TestEventsResourceExhausted(t *testing.T) {
183+ f := &fakeEvents{err: connect.NewError(connect.CodeResourceExhausted, errors.New("too many watchers"))}
184+ ts := eventsServer(t, f, time.Hour, 0)
185+ resp, _ := open(t, ts, "topic=home", nil)
186+ assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
187+ assert.NotEmpty(t, resp.Header.Get("Retry-After"))
188+ assert.NotContains(t, resp.Header.Get("Content-Type"), "event-stream")
189+
190+ f.err = connect.NewError(connect.CodeNotFound, errors.New("x"))
191+ resp, _ = open(t, ts, "topic=home", nil)
192+ assert.Equal(t, http.StatusNotFound, resp.StatusCode)
193+ f.err = errors.New("boom")
194+ resp, _ = open(t, ts, "topic=home", nil)
195+ assert.Equal(t, http.StatusInternalServerError, resp.StatusCode)
196+}
197+
198+// The handler returns, and the watch it opened ends, when the visitor goes
199+// away or the API ends the stream.
200+func TestEventsNoLeak(t *testing.T) {
201+ f := &fakeEvents{release: make(chan struct{})}
202+ ts := eventsServer(t, f, time.Hour, 0)
203+
204+ resp, cancel := open(t, ts, "topic=home", nil)
205+ require.Equal(t, http.StatusOK, resp.StatusCode)
206+ require.Eventually(t, func() bool { return f.opened.Load() == 1 }, 5*time.Second, time.Millisecond)
207+ cancel()
208+ require.Eventually(t, func() bool { return f.closed.Load() == 1 }, 5*time.Second, time.Millisecond, "the visitor left")
209+
210+ resp, _ = open(t, ts, "topic=home", nil)
211+ require.Equal(t, http.StatusOK, resp.StatusCode)
212+ require.Eventually(t, func() bool { return f.opened.Load() == 2 }, 5*time.Second, time.Millisecond)
213+ close(f.release) // the API ends the stream (shutdown)
214+ done := make(chan struct{})
215+ go func() { _, _ = bufio.NewReader(resp.Body).ReadString(0); close(done) }()
216+ select {
217+ case <-done:
218+ case <-time.After(5 * time.Second):
219+ t.Fatal("the stream did not end with the API's")
220+ }
221+ require.Eventually(t, func() bool { return f.closed.Load() == 2 }, 5*time.Second, time.Millisecond)
222+}
223+
224+func TestEventsOffWithoutClient(t *testing.T) {
225+ srv, err := New(Config{
226+ Repos: ermiteconnect.NewRepoServiceClient(http.DefaultClient, "http://x.invalid"),
227+ Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://x.invalid"),
228+ PublicURL: "https://forge.example",
229+ })
230+ require.NoError(t, err)
231+ rec := httptest.NewRecorder()
232+ srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/events?topic=home", nil))
233+ assert.Equal(t, http.StatusNotFound, rec.Code)
234+}
A internal/adapters/web/fragments.go +203 -0
@@ -0,0 +1,203 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
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.
17+type 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.
26+func 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+
34+var gap = []fragment{{event: "gap", html: "gap"}}
35+
36+// render returns the fragments for an event, none when it changes nothing on
37+// the page.
38+func (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.
55+func (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+
83+func (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.
89+func (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":
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.
119+func (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.
129+func (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.
145+func (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+ n, found := 0, false
170+ for _, c := range res.Msg.GetCommits() {
171+ if c.GetId() == l.head {
172+ found = true
173+ break
174+ }
175+ n++
176+ }
177+ switch {
178+ case found:
179+ text = newCommits(strconv.Itoa(n), n)
180+ case res.Msg.GetNextPageToken() != "":
181+ text = newCommits(strconv.Itoa(n)+"+", 2)
182+ default:
183+ text = "the branch was rewritten — reload"
184+ }
185+ }
186+ c := CommitsSlot()
187+ if text != "" {
188+ c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
189+ }
190+ f, err := renderFragment(ctx, "commits-head", c)
191+ return []fragment{f}, err
192+}
193+
194+// newCommits is the text of the banner: "1 new commit — reload".
195+func newCommits(count string, n int) string {
196+ if n == 1 {
197+ return count + " new commit — reload"
198+ }
199+ return count + " new commits — reload"
200+}
201+
202+// isFullID reports whether s is a full commit id.
203+func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
@@ -0,0 +1,203 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+import (
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.
17+type 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.
26+func 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+
34+var gap = []fragment{{event: "gap", html: "gap"}}
35+
36+// render returns the fragments for an event, none when it changes nothing on
37+// the page.
38+func (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.
55+func (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+
83+func (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.
89+func (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":
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.
119+func (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.
129+func (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.
145+func (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+ n, found := 0, false
170+ for _, c := range res.Msg.GetCommits() {
171+ if c.GetId() == l.head {
172+ found = true
173+ break
174+ }
175+ n++
176+ }
177+ switch {
178+ case found:
179+ text = newCommits(strconv.Itoa(n), n)
180+ case res.Msg.GetNextPageToken() != "":
181+ text = newCommits(strconv.Itoa(n)+"+", 2)
182+ default:
183+ text = "the branch was rewritten — reload"
184+ }
185+ }
186+ c := CommitsSlot()
187+ if text != "" {
188+ c = CommitsBanner(text, repoHref(l.repo, "commits", l.ref))
189+ }
190+ f, err := renderFragment(ctx, "commits-head", c)
191+ return []fragment{f}, err
192+}
193+
194+// newCommits is the text of the banner: "1 new commit — reload".
195+func newCommits(count string, n int) string {
196+ if n == 1 {
197+ return count + " new commit — reload"
198+ }
199+ return count + " new commits — reload"
200+}
201+
202+// isFullID reports whether s is a full commit id.
203+func isFullID(s string) bool { return len(s) == 40 && isCommitID(s) }
A internal/adapters/web/fragments.templ +92 -0
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// The fragments of the live updates (events.go). Each is a top-level element
6+// with an id: the page swaps the element that has the same id.
7+
8+// FactValue is the value of a fact; its id, when it has one, is the target of
9+// the live updates ("last push").
10+templ FactValue(f Fact) {
11+ <dd
12+ if f.ID != "" {
13+ id={ f.ID }
14+ }
15+ >
16+ if f.Time != "" {
17+ <time datetime={ f.Time }>{ f.Val }</time>
18+ } else {
19+ { f.Val }
20+ }
21+ </dd>
22+}
23+
24+// RepoGone replaces the row of a repository that is deleted or not visible
25+// any more; the page removes the element.
26+templ RepoGone(id string) {
27+ <tr id={ id } data-gone hidden></tr>
28+}
29+
30+// RefsBody is the rows of the refs table.
31+templ RefsBody(p RefsPage) {
32+ <tbody id="ref-rows" data-rows>
33+ for _, b := range p.Branches {
34+ @refRow("branch", b)
35+ }
36+ for _, t := range p.Tags {
37+ @refRow("tag", t)
38+ }
39+ </tbody>
40+}
41+
42+// RefsKV is the facts of the rail of the refs page.
43+templ RefsKV(p RefsPage) {
44+ <dl class="kv" id="refs-kv">
45+ <dt>default</dt>
46+ <dd>{ p.Default }</dd>
47+ <dt>branches</dt>
48+ <dd>{ itoa(len(p.Branches)) }</dd>
49+ <dt>tags</dt>
50+ <dd>{ itoa(len(p.Tags)) }</dd>
51+ if p.Latest != "" {
52+ <dt>latest tag</dt>
53+ <dd>{ p.Latest }</dd>
54+ }
55+ </dl>
56+}
57+
58+// CommitsSlot is the place of the banner of the commits page, empty.
59+templ CommitsSlot() {
60+ <div id="commits-head"></div>
61+}
62+
63+// CommitsBanner announces new commits on the branch the page lists. It links
64+// to the first page of the branch.
65+templ CommitsBanner(text, href string) {
66+ <div id="commits-head" class="live-banner" role="status">
67+ <a href={ templ.SafeURL(href) }>{ text }</a>
68+ </div>
69+}
70+
71+// Live is the connection state of the event stream, in the status bar.
72+// Without a stream it reads "offline" and does nothing. The events of the
73+// stream are fragments that ermiteLive (ermite.js) swaps in the page.
74+templ Live(l Layout) {
75+ if l.Live && l.LiveURL != "" {
76+ <span
77+ class="st-live"
78+ data-state="offline"
79+ role="status"
80+ hx-sse:connect={ l.LiveURL }
81+ hx-swap="none"
82+ :text="data.state"
83+ :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'"
84+ hx-on="htmx:sse:after:connection -> data.state = 'live';
85+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
86+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
87+ gap -> ermiteLive.reload()"
88+ >offline</span>
89+ } else {
90+ <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
91+ }
92+}
@@ -0,0 +1,92 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package web
4+
5+// The fragments of the live updates (events.go). Each is a top-level element
6+// with an id: the page swaps the element that has the same id.
7+
8+// FactValue is the value of a fact; its id, when it has one, is the target of
9+// the live updates ("last push").
10+templ FactValue(f Fact) {
11+ <dd
12+ if f.ID != "" {
13+ id={ f.ID }
14+ }
15+ >
16+ if f.Time != "" {
17+ <time datetime={ f.Time }>{ f.Val }</time>
18+ } else {
19+ { f.Val }
20+ }
21+ </dd>
22+}
23+
24+// RepoGone replaces the row of a repository that is deleted or not visible
25+// any more; the page removes the element.
26+templ RepoGone(id string) {
27+ <tr id={ id } data-gone hidden></tr>
28+}
29+
30+// RefsBody is the rows of the refs table.
31+templ RefsBody(p RefsPage) {
32+ <tbody id="ref-rows" data-rows>
33+ for _, b := range p.Branches {
34+ @refRow("branch", b)
35+ }
36+ for _, t := range p.Tags {
37+ @refRow("tag", t)
38+ }
39+ </tbody>
40+}
41+
42+// RefsKV is the facts of the rail of the refs page.
43+templ RefsKV(p RefsPage) {
44+ <dl class="kv" id="refs-kv">
45+ <dt>default</dt>
46+ <dd>{ p.Default }</dd>
47+ <dt>branches</dt>
48+ <dd>{ itoa(len(p.Branches)) }</dd>
49+ <dt>tags</dt>
50+ <dd>{ itoa(len(p.Tags)) }</dd>
51+ if p.Latest != "" {
52+ <dt>latest tag</dt>
53+ <dd>{ p.Latest }</dd>
54+ }
55+ </dl>
56+}
57+
58+// CommitsSlot is the place of the banner of the commits page, empty.
59+templ CommitsSlot() {
60+ <div id="commits-head"></div>
61+}
62+
63+// CommitsBanner announces new commits on the branch the page lists. It links
64+// to the first page of the branch.
65+templ CommitsBanner(text, href string) {
66+ <div id="commits-head" class="live-banner" role="status">
67+ <a href={ templ.SafeURL(href) }>{ text }</a>
68+ </div>
69+}
70+
71+// Live is the connection state of the event stream, in the status bar.
72+// Without a stream it reads "offline" and does nothing. The events of the
73+// stream are fragments that ermiteLive (ermite.js) swaps in the page.
74+templ Live(l Layout) {
75+ if l.Live && l.LiveURL != "" {
76+ <span
77+ class="st-live"
78+ data-state="offline"
79+ role="status"
80+ hx-sse:connect={ l.LiveURL }
81+ hx-swap="none"
82+ :text="data.state"
83+ :title="data.state === 'live' ? 'receiving updates over SSE' : 'not connected to the event stream'"
84+ hx-on="htmx:sse:after:connection -> data.state = 'live';
85+ htmx:sse:close, htmx:sse:error -> data.state = 'offline';
86+ home-list, repo-last-push, refs-list, commits-head -> ermiteLive.swap(event.detail.data);
87+ gap -> ermiteLive.reload()"
88+ >offline</span>
89+ } else {
90+ <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
91+ }
92+}
A internal/adapters/web/fragments_templ.go +437 -0 generated file: collapsed
M internal/adapters/web/home.go +1 -0
@@ -108,6 +108,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
108108
109109 l := s.base()
110110 l.Title = s.instance
111+ l.Live, l.LiveURL = true, liveURL("home", "")
111112 l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}}
112113 dir := "▲"
113114 if desc {
@@ -108,6 +108,7 @@ func (s *Server) home(ctx context.Context, r *http.Request) (templ.Component, in
108 108
109 l := s.base()109 l := s.base()
110 l.Title = s.instance110 l.Title = s.instance
111+ l.Live, l.LiveURL = true, liveURL("home", "")
111 l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}}112 l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}}
112 dir := "▲"113 dir := "▲"
113 if desc {114 if desc {
M internal/adapters/web/pages.templ +2 -20
@@ -92,14 +92,7 @@ templ Refs(l Layout, p RefsPage) {
9292 <thead>
9393 <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
9494 </thead>
95- <tbody id="ref-rows" data-rows>
96- for _, b := range p.Branches {
97- @refRow("branch", b)
98- }
99- for _, t := range p.Tags {
100- @refRow("tag", t)
101- }
102- </tbody>
95+ @RefsBody(p)
10396 </table>
10497 if p.Note != "" {
10598 <p class="note">{ p.Note }</p>
@@ -136,18 +129,7 @@ templ refRow(kind string, r RefRow) {
136129 templ refsRail(p RefsPage) {
137130 <section aria-labelledby="r-refs">
138131 <h2 id="r-refs">refs</h2>
139- <dl class="kv">
140- <dt>default</dt>
141- <dd>{ p.Default }</dd>
142- <dt>branches</dt>
143- <dd>{ itoa(len(p.Branches)) }</dd>
144- <dt>tags</dt>
145- <dd>{ itoa(len(p.Tags)) }</dd>
146- if p.Latest != "" {
147- <dt>latest tag</dt>
148- <dd>{ p.Latest }</dd>
149- }
150- </dl>
132+ @RefsKV(p)
151133 </section>
152134 <section aria-labelledby="r-legend">
153135 <h2 id="r-legend">legend</h2>
@@ -92,14 +92,7 @@ templ Refs(l Layout, p RefsPage) {
92 <thead>92 <thead>
93 <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>93 <tr><th class="cur"></th><th class="c-kind">kind</th><th class="c-name">name</th><th class="c-sha">commit</th><th class="c-sub">subject</th><th class="c-when">date</th></tr>
94 </thead>94 </thead>
95- <tbody id="ref-rows" data-rows>95+ @RefsBody(p)
96- for _, b := range p.Branches {
97- @refRow("branch", b)
98- }
99- for _, t := range p.Tags {
100- @refRow("tag", t)
101- }
102- </tbody>
103 </table>96 </table>
104 if p.Note != "" {97 if p.Note != "" {
105 <p class="note">{ p.Note }</p>98 <p class="note">{ p.Note }</p>
@@ -136,18 +129,7 @@ templ refRow(kind string, r RefRow) {
136 templ refsRail(p RefsPage) {129 templ refsRail(p RefsPage) {
137 <section aria-labelledby="r-refs">130 <section aria-labelledby="r-refs">
138 <h2 id="r-refs">refs</h2>131 <h2 id="r-refs">refs</h2>
139- <dl class="kv">132+ @RefsKV(p)
140- <dt>default</dt>
141- <dd>{ p.Default }</dd>
142- <dt>branches</dt>
143- <dd>{ itoa(len(p.Branches)) }</dd>
144- <dt>tags</dt>
145- <dd>{ itoa(len(p.Tags)) }</dd>
146- if p.Latest != "" {
147- <dt>latest tag</dt>
148- <dd>{ p.Latest }</dd>
149- }
150- </dl>
151 </section>133 </section>
152 <section aria-labelledby="r-legend">134 <section aria-labelledby="r-legend">
153 <h2 id="r-legend">legend</h2>135 <h2 id="r-legend">legend</h2>
M internal/adapters/web/pages_templ.go +54 -116 generated file: collapsed
M internal/adapters/web/refspage.go +19 -11
@@ -19,6 +19,24 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
1919 if err != nil {
2020 return nil, 0, err
2121 }
22+ p := s.refsPage(ctx, d)
23+
24+ l := s.repoLayout(ctx, d, "refs")
25+ l.Title = "refs · " + d.name() + " · " + s.instance
26+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs")
27+ l.Crumbs[0].Current = false
28+ l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
29+ l.Status = Status{
30+ Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
31+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
32+ }
33+ l.HasRail, l.RailSum = true, "refs · legend"
34+ return Refs(l, p), http.StatusOK, nil
35+}
36+
37+// refsPage is the content of the refs page, which the live updates of the
38+// page render again.
39+func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
2240 all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
2341 commits := s.commitsOf(ctx, d.name(), all)
2442
@@ -45,17 +63,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
4563 if d.cut {
4664 p.Note = "this repository has more refs than are listed here."
4765 }
48-
49- l := s.repoLayout(ctx, d, "refs")
50- l.Title = "refs · " + d.name() + " · " + s.instance
51- l.Crumbs[0].Current = false
52- l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
53- l.Status = Status{
54- Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
55- Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
56- }
57- l.HasRail, l.RailSum = true, "refs · legend"
58- return Refs(l, p), http.StatusOK, nil
66+ return p
5967 }
6068
6169 func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
@@ -19,6 +19,24 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
19 if err != nil {19 if err != nil {
20 return nil, 0, err20 return nil, 0, err
21 }21 }
22+ p := s.refsPage(ctx, d)
23+
24+ l := s.repoLayout(ctx, d, "refs")
25+ l.Title = "refs · " + d.name() + " · " + s.instance
26+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=refs")
27+ l.Crumbs[0].Current = false
28+ l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
29+ l.Status = Status{
30+ Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
31+ Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
32+ }
33+ l.HasRail, l.RailSum = true, "refs · legend"
34+ return Refs(l, p), http.StatusOK, nil
35+}
36+
37+// refsPage is the content of the refs page, which the live updates of the
38+// page render again.
39+func (s *Server) refsPage(ctx context.Context, d *repoData) RefsPage {
22 all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)40 all := append(append([]*pb.Ref(nil), d.branches...), d.tags...)
23 commits := s.commitsOf(ctx, d.name(), all)41 commits := s.commitsOf(ctx, d.name(), all)
24 42
@@ -45,17 +63,7 @@ func (s *Server) refs(ctx context.Context, r *http.Request) (templ.Component, in
45 if d.cut {63 if d.cut {
46 p.Note = "this repository has more refs than are listed here."64 p.Note = "this repository has more refs than are listed here."
47 }65 }
48-66+ return p
49- l := s.repoLayout(ctx, d, "refs")
50- l.Title = "refs · " + d.name() + " · " + s.instance
51- l.Crumbs[0].Current = false
52- l.Crumbs = append(l.Crumbs, Crumb{Label: "refs", Current: true})
53- l.Status = Status{
54- Mode: "refs", Context: plural(len(p.Branches), "branch", "branches") + " · " + plural(len(p.Tags), "tag", "tags"),
55- Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(p.Branches) + len(p.Tags),
56- }
57- l.HasRail, l.RailSum = true, "refs · legend"
58- return Refs(l, p), http.StatusOK, nil
59 }67 }
60 68
61 func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {69 func (s *Server) refRow(d *repoData, r *pb.Ref, commits map[string]refCommit, isDefault, cursor bool) RefRow {
M internal/adapters/web/shell.templ +1 -22
@@ -119,7 +119,7 @@ templ Statusbar(l Layout) {
119119 <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
120120 <span class="st-hints">{ l.Status.Hints }</span>
121121 @Footer(l)
122- @Live(l.Live)
122+ @Live(l)
123123 if l.Status.Cursor {
124124 <span
125125 class="st-pos num"
@@ -135,24 +135,3 @@ templ Statusbar(l Layout) {
135135 templ Footer(l Layout) {
136136 <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span>
137137 }
138-
139-// Live is the connection state of the event stream. Without the stream it
140-// reads "offline" and does nothing.
141-templ Live(on bool) {
142- if on {
143- <span
144- class="st-live"
145- data-state="offline"
146- role="status"
147- hx-sse:connect="/-/events"
148- hx-swap="none"
149- :text="data.state"
150- :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'"
151- hx-on="htmx:sse:after:connection -> data.state = 'live';
152- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
153- push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)"
154- >offline</span>
155- } else {
156- <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
157- }
158-}
@@ -119,7 +119,7 @@ templ Statusbar(l Layout) {
119 <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>119 <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span>
120 <span class="st-hints">{ l.Status.Hints }</span>120 <span class="st-hints">{ l.Status.Hints }</span>
121 @Footer(l)121 @Footer(l)
122- @Live(l.Live)122+ @Live(l)
123 if l.Status.Cursor {123 if l.Status.Cursor {
124 <span124 <span
125 class="st-pos num"125 class="st-pos num"
@@ -135,24 +135,3 @@ templ Statusbar(l Layout) {
135 templ Footer(l Layout) {135 templ Footer(l Layout) {
136 <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span>136 <span class="st-src"><span class="st-ver">ermite { l.Version } · </span><a href={ templ.SafeURL(l.SourceURL) } rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a></span>
137 }137 }
138-
139-// Live is the connection state of the event stream. Without the stream it
140-// reads "offline" and does nothing.
141-templ Live(on bool) {
142- if on {
143- <span
144- class="st-live"
145- data-state="offline"
146- role="status"
147- hx-sse:connect="/-/events"
148- hx-swap="none"
149- :text="data.state"
150- :title="data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'"
151- hx-on="htmx:sse:after:connection -> data.state = 'live';
152- htmx:sse:close, htmx:sse:error -> data.state = 'offline';
153- push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)"
154- >offline</span>
155- } else {
156- <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span>
157- }
158-}
M internal/adapters/web/shell_templ.go +1 -39
@@ -556,7 +556,7 @@ func Statusbar(l Layout) templ.Component {
556556 if templ_7745c5c3_Err != nil {
557557 return templ_7745c5c3_Err
558558 }
559- templ_7745c5c3_Err = Live(l.Live).Render(ctx, templ_7745c5c3_Buffer)
559+ templ_7745c5c3_Err = Live(l).Render(ctx, templ_7745c5c3_Buffer)
560560 if templ_7745c5c3_Err != nil {
561561 return templ_7745c5c3_Err
562562 }
@@ -644,42 +644,4 @@ func Footer(l Layout) templ.Component {
644644 })
645645 }
646646
647-// Live is the connection state of the event stream. Without the stream it
648-// reads "offline" and does nothing.
649-func Live(on bool) templ.Component {
650- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
651- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
652- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
653- return templ_7745c5c3_CtxErr
654- }
655- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
656- if !templ_7745c5c3_IsBuffer {
657- defer func() {
658- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
659- if templ_7745c5c3_Err == nil {
660- templ_7745c5c3_Err = templ_7745c5c3_BufErr
661- }
662- }()
663- }
664- ctx = templ.InitializeContext(ctx)
665- templ_7745c5c3_Var33 := templ.GetChildren(ctx)
666- if templ_7745c5c3_Var33 == nil {
667- templ_7745c5c3_Var33 = templ.NopComponent
668- }
669- ctx = templ.ClearChildren(ctx)
670- if on {
671- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"st-live\" data-state=\"offline\" role=\"status\" hx-sse:connect=\"/-/events\" hx-swap=\"none\" :text=\"data.state\" :title=\"data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'\" hx-on=\"htmx:sse:after:connection -> data.state = 'live';\n htmx:sse:close, htmx:sse:error -> data.state = 'offline';\n push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)\">offline</span>")
672- if templ_7745c5c3_Err != nil {
673- return templ_7745c5c3_Err
674- }
675- } else {
676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"st-live\" data-state=\"offline\" role=\"status\" title=\"not connected to the event stream\">offline</span>")
677- if templ_7745c5c3_Err != nil {
678- return templ_7745c5c3_Err
679- }
680- }
681- return nil
682- })
683-}
684-
685647 var _ = templruntime.GeneratedTemplate
@@ -556,7 +556,7 @@ func Statusbar(l Layout) templ.Component {
556 if templ_7745c5c3_Err != nil {556 if templ_7745c5c3_Err != nil {
557 return templ_7745c5c3_Err557 return templ_7745c5c3_Err
558 }558 }
559- templ_7745c5c3_Err = Live(l.Live).Render(ctx, templ_7745c5c3_Buffer)559+ templ_7745c5c3_Err = Live(l).Render(ctx, templ_7745c5c3_Buffer)
560 if templ_7745c5c3_Err != nil {560 if templ_7745c5c3_Err != nil {
561 return templ_7745c5c3_Err561 return templ_7745c5c3_Err
562 }562 }
@@ -644,42 +644,4 @@ func Footer(l Layout) templ.Component {
644 })644 })
645 }645 }
646 646
647-// Live is the connection state of the event stream. Without the stream it
648-// reads "offline" and does nothing.
649-func Live(on bool) templ.Component {
650- return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
651- templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
652- if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
653- return templ_7745c5c3_CtxErr
654- }
655- templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
656- if !templ_7745c5c3_IsBuffer {
657- defer func() {
658- templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
659- if templ_7745c5c3_Err == nil {
660- templ_7745c5c3_Err = templ_7745c5c3_BufErr
661- }
662- }()
663- }
664- ctx = templ.InitializeContext(ctx)
665- templ_7745c5c3_Var33 := templ.GetChildren(ctx)
666- if templ_7745c5c3_Var33 == nil {
667- templ_7745c5c3_Var33 = templ.NopComponent
668- }
669- ctx = templ.ClearChildren(ctx)
670- if on {
671- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"st-live\" data-state=\"offline\" role=\"status\" hx-sse:connect=\"/-/events\" hx-swap=\"none\" :text=\"data.state\" :title=\"data.state === 'live' ? 'receiving pushes over SSE' : 'not connected to the event stream'\" hx-on=\"htmx:sse:after:connection -> data.state = 'live';\n htmx:sse:close, htmx:sse:error -> data.state = 'offline';\n push -> for (let el of new DOMParser().parseFromString('<template>' + event.detail.data + '</template>', 'text/html').querySelector('template').content.children) q('#' + el.id).insert('replace', el.outerHTML)\">offline</span>")
672- if templ_7745c5c3_Err != nil {
673- return templ_7745c5c3_Err
674- }
675- } else {
676- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"st-live\" data-state=\"offline\" role=\"status\" title=\"not connected to the event stream\">offline</span>")
677- if templ_7745c5c3_Err != nil {
678- return templ_7745c5c3_Err
679- }
680- }
681- return nil
682- })
683-}
684-
685 var _ = templruntime.GeneratedTemplate647 var _ = templruntime.GeneratedTemplate
A internal/adapters/web/static/css/80-live.css +6 -0
@@ -0,0 +1,6 @@
1+@layer components {
2+ /* live updates: the banner of new commits, an empty slot takes no room */
3+ #commits-head:empty { display: none; }
4+ .live-banner { margin: var(--lh) 0; padding: 0 1ch; background: var(--row); font-weight: bold; }
5+ .live-banner a { color: var(--ink); }
6+}
@@ -0,0 +1,6 @@
1+@layer components {
2+ /* live updates: the banner of new commits, an empty slot takes no room */
3+ #commits-head:empty { display: none; }
4+ .live-banner { margin: var(--lh) 0; padding: 0 1ch; background: var(--row); font-weight: bold; }
5+ .live-banner a { color: var(--ink); }
6+}
M internal/adapters/web/static/js/ermite.js +34 -0
@@ -30,3 +30,37 @@
3030 // PageUp/PageDown and space scroll the content without a click first.
3131 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
3232 })();
33+
34+// ermiteLive applies the fragments of the event stream (/-/events, see
35+// internal/adapters/web/events.go). A fragment is a top-level element with an
36+// id: it replaces the element of the page that has the same id. A fragment
37+// marked data-gone removes it; a row of a repository the home page does not
38+// list yet is added at the top of the list. A gap, which says the page may
39+// have missed events or that what it shows changed, reloads the page.
40+window.ermiteLive = (() => {
41+ let reloading = false;
42+ return {
43+ swap(html) {
44+ const t = document.createElement('template');
45+ t.innerHTML = html;
46+ for (const el of t.content.children) {
47+ const old = el.id && document.getElementById(el.id);
48+ if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
49+ if (!old) {
50+ const rows = document.getElementById('repo-rows');
51+ if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
52+ continue;
53+ }
54+ if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
55+ if (old.hidden && old.tagName === 'TR') el.hidden = true;
56+ old.replaceWith(el);
57+ window.htmx?.process(el);
58+ }
59+ },
60+ reload() {
61+ if (reloading) return;
62+ reloading = true;
63+ setTimeout(() => location.reload(), 300);
64+ },
65+ };
66+})();
@@ -30,3 +30,37 @@
30 // PageUp/PageDown and space scroll the content without a click first.30 // PageUp/PageDown and space scroll the content without a click first.
31 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });31 if (!location.hash) document.getElementById('main')?.focus({ preventScroll: true });
32 })();32 })();
33+
34+// ermiteLive applies the fragments of the event stream (/-/events, see
35+// internal/adapters/web/events.go). A fragment is a top-level element with an
36+// id: it replaces the element of the page that has the same id. A fragment
37+// marked data-gone removes it; a row of a repository the home page does not
38+// list yet is added at the top of the list. A gap, which says the page may
39+// have missed events or that what it shows changed, reloads the page.
40+window.ermiteLive = (() => {
41+ let reloading = false;
42+ return {
43+ swap(html) {
44+ const t = document.createElement('template');
45+ t.innerHTML = html;
46+ for (const el of t.content.children) {
47+ const old = el.id && document.getElementById(el.id);
48+ if (el.hasAttribute('data-gone')) { old?.remove(); continue; }
49+ if (!old) {
50+ const rows = document.getElementById('repo-rows');
51+ if (rows && el.id.startsWith('repo-')) { rows.prepend(el); window.htmx?.process(el); }
52+ continue;
53+ }
54+ if (old.hasAttribute('data-cursor')) el.setAttribute('data-cursor', '');
55+ if (old.hidden && old.tagName === 'TR') el.hidden = true;
56+ old.replaceWith(el);
57+ window.htmx?.process(el);
58+ }
59+ },
60+ reload() {
61+ if (reloading) return;
62+ reloading = true;
63+ setTimeout(() => location.reload(), 300);
64+ },
65+ };
66+})();
M internal/adapters/web/summary.go +13 -6
@@ -78,6 +78,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
7878
7979 l := s.repoLayout(ctx, d, "summary")
8080 l.Title = d.name() + " · " + s.instance
81+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
8182 l.Status = Status{
8283 Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
8384 Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
@@ -92,13 +93,8 @@ const maxTagDates = 20
9293
9394 // facts are the key/value rows of the rail.
9495 func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
95- last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()}
96- if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
97- last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
98- last.Time = rfc3339(t.AsTime())
99- }
10096 fs := []Fact{
101- last,
97+ lastPushFact(d.repo, now),
10298 {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
10399 }
104100 if n := len(d.tags); n > 0 {
@@ -116,6 +112,17 @@ func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
116112 return fs
117113 }
118114
115+// lastPushFact is the "last push" fact of a repository; the live updates
116+// render it again.
117+func lastPushFact(r *pb.Repo, now time.Time) Fact {
118+ last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
119+ if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
120+ last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
121+ last.Time = rfc3339(t.AsTime())
122+ }
123+ return last
124+}
125+
119126 // latestTag is the tag whose commit is the newest.
120127 func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
121128 var name string
@@ -78,6 +78,7 @@ func (s *Server) summary(ctx context.Context, r *http.Request) (templ.Component,
78 78
79 l := s.repoLayout(ctx, d, "summary")79 l := s.repoLayout(ctx, d, "summary")
80 l.Title = d.name() + " · " + s.instance80 l.Title = d.name() + " · " + s.instance
81+ l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary")
81 l.Status = Status{82 l.Status = Status{
82 Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),83 Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"),
83 Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),84 Hints: "j/k move · enter open · b ref · y permalink · ? help", Cursor: true, Count: len(p.Recent),
@@ -92,13 +93,8 @@ const maxTagDates = 20
92 93
93 // facts are the key/value rows of the rail.94 // facts are the key/value rows of the rail.
94 func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {95 func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
95- last := Fact{Key: "last push", Val: "never", ID: "last-push-" + d.name()}
96- if t := d.repo.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
97- last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
98- last.Time = rfc3339(t.AsTime())
99- }
100 fs := []Fact{96 fs := []Fact{
101- last,97+ lastPushFact(d.repo, now),
102 {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},98 {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")},
103 }99 }
104 if n := len(d.tags); n > 0 {100 if n := len(d.tags); n > 0 {
@@ -116,6 +112,17 @@ func (s *Server) facts(ctx context.Context, d *repoData, now time.Time) []Fact {
116 return fs112 return fs
117 }113 }
118 114
115+// lastPushFact is the "last push" fact of a repository; the live updates
116+// render it again.
117+func lastPushFact(r *pb.Repo, now time.Time) Fact {
118+ last := Fact{Key: "last push", Val: "never", ID: "last-push-" + r.GetName()}
119+ if t := r.GetPushedAt(); t.IsValid() && t.GetSeconds() != 0 {
120+ last.Val = ago(now, t.AsTime()) + " · " + absolute(t.AsTime())
121+ last.Time = rfc3339(t.AsTime())
122+ }
123+ return last
124+}
125+
119 // latestTag is the tag whose commit is the newest.126 // latestTag is the tag whose commit is the newest.
120 func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {127 func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) {
121 var name string128 var name string
M internal/adapters/web/view.go +4 -0
@@ -65,6 +65,8 @@ type Layout struct {
6565 Read bool // prose page: content capped at the reading measure
6666 // Live connects the status bar to the event stream (/-/events).
6767 Live bool
68+ // LiveURL is the stream the page follows, with its query.
69+ LiveURL string
6870 }
6971
7072 // Link is a plain label + href pair.
@@ -81,6 +83,8 @@ type HomeRow struct {
8183 Branch string
8284 Tag string
8385 Cursor bool
86+ // Fresh marks a row that a push just updated (live updates only).
87+ Fresh bool
8488 }
8589
8690 // SortCol is a column header of the home list; it links to its sort order.
@@ -65,6 +65,8 @@ type Layout struct {
65 Read bool // prose page: content capped at the reading measure65 Read bool // prose page: content capped at the reading measure
66 // Live connects the status bar to the event stream (/-/events).66 // Live connects the status bar to the event stream (/-/events).
67 Live bool67 Live bool
68+ // LiveURL is the stream the page follows, with its query.
69+ LiveURL string
68 }70 }
69 71
70 // Link is a plain label + href pair.72 // Link is a plain label + href pair.
@@ -81,6 +83,8 @@ type HomeRow struct {
81 Branch string83 Branch string
82 Tag string84 Tag string
83 Cursor bool85 Cursor bool
86+ // Fresh marks a row that a push just updated (live updates only).
87+ Fresh bool
84 }88 }
85 89
86 // SortCol is a column header of the home list; it links to its sort order.90 // SortCol is a column header of the home list; it links to its sort order.
M internal/adapters/web/web.go +7 -0
@@ -47,6 +47,12 @@ type Config struct {
4747 // Repos and Git are the API clients the pages are built from.
4848 Repos ermiteconnect.RepoServiceClient
4949 Git ermiteconnect.GitServiceClient
50+ // Events is the client of the event stream behind /-/events. Without
51+ // it the pages are not live and the route answers 404.
52+ Events ermiteconnect.EventServiceClient
53+ // Heartbeat is how often an idle event stream gets a comment line;
54+ // DefaultHeartbeat when zero.
55+ Heartbeat time.Duration
5056 // PublicURL is the base URL of the instance, without a trailing slash:
5157 // the clone URLs are built on it, and its host is the instance name.
5258 PublicURL string
@@ -94,6 +100,7 @@ func New(cfg Config) (*Server, error) {
94100 func (s *Server) routes() *http.ServeMux {
95101 mux := http.NewServeMux()
96102 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
103+ mux.HandleFunc("GET /-/events", s.events)
97104 mux.HandleFunc("GET /{$}", s.page(s.home))
98105 mux.HandleFunc("GET /{repo}", s.page(s.summary))
99106 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))
@@ -47,6 +47,12 @@ type Config struct {
47 // Repos and Git are the API clients the pages are built from.47 // Repos and Git are the API clients the pages are built from.
48 Repos ermiteconnect.RepoServiceClient48 Repos ermiteconnect.RepoServiceClient
49 Git ermiteconnect.GitServiceClient49 Git ermiteconnect.GitServiceClient
50+ // Events is the client of the event stream behind /-/events. Without
51+ // it the pages are not live and the route answers 404.
52+ Events ermiteconnect.EventServiceClient
53+ // Heartbeat is how often an idle event stream gets a comment line;
54+ // DefaultHeartbeat when zero.
55+ Heartbeat time.Duration
50 // PublicURL is the base URL of the instance, without a trailing slash:56 // PublicURL is the base URL of the instance, without a trailing slash:
51 // the clone URLs are built on it, and its host is the instance name.57 // the clone URLs are built on it, and its host is the instance name.
52 PublicURL string58 PublicURL string
@@ -94,6 +100,7 @@ func New(cfg Config) (*Server, error) {
94 func (s *Server) routes() *http.ServeMux {100 func (s *Server) routes() *http.ServeMux {
95 mux := http.NewServeMux()101 mux := http.NewServeMux()
96 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)102 mux.HandleFunc("GET "+StaticPrefix, s.assets.serve)
103+ mux.HandleFunc("GET /-/events", s.events)
97 mux.HandleFunc("GET /{$}", s.page(s.home))104 mux.HandleFunc("GET /{$}", s.page(s.home))
98 mux.HandleFunc("GET /{repo}", s.page(s.summary))105 mux.HandleFunc("GET /{repo}", s.page(s.summary))
99 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))106 mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary))

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