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
@@ -50,81 +50,16 @@ func Facts(facts []Fact) templ.Component {
5050 if templ_7745c5c3_Err != nil {
5151 return templ_7745c5c3_Err
5252 }
53- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</dt><dd")
53+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</dt>")
5454 if templ_7745c5c3_Err != nil {
5555 return templ_7745c5c3_Err
5656 }
57- if f.ID != "" {
58- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " id=\"")
59- if templ_7745c5c3_Err != nil {
60- return templ_7745c5c3_Err
61- }
62- var templ_7745c5c3_Var3 string
63- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
64- if templ_7745c5c3_Err != nil {
65- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 12, Col: 14}
66- }
67- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
68- if templ_7745c5c3_Err != nil {
69- return templ_7745c5c3_Err
70- }
71- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"")
72- if templ_7745c5c3_Err != nil {
73- return templ_7745c5c3_Err
74- }
75- }
76- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ">")
77- if templ_7745c5c3_Err != nil {
78- return templ_7745c5c3_Err
79- }
80- if f.Time != "" {
81- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<time datetime=\"")
82- if templ_7745c5c3_Err != nil {
83- return templ_7745c5c3_Err
84- }
85- var templ_7745c5c3_Var4 string
86- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Time)
87- if templ_7745c5c3_Err != nil {
88- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 16, Col: 28}
89- }
90- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
91- if templ_7745c5c3_Err != nil {
92- return templ_7745c5c3_Err
93- }
94- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
95- if templ_7745c5c3_Err != nil {
96- return templ_7745c5c3_Err
97- }
98- var templ_7745c5c3_Var5 string
99- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(f.Val)
100- if templ_7745c5c3_Err != nil {
101- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 16, Col: 38}
102- }
103- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
104- if templ_7745c5c3_Err != nil {
105- return templ_7745c5c3_Err
106- }
107- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</time>")
108- if templ_7745c5c3_Err != nil {
109- return templ_7745c5c3_Err
110- }
111- } else {
112- var templ_7745c5c3_Var6 string
113- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(f.Val)
114- if templ_7745c5c3_Err != nil {
115- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 18, Col: 12}
116- }
117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
118- if templ_7745c5c3_Err != nil {
119- return templ_7745c5c3_Err
120- }
121- }
122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</dd>")
57+ templ_7745c5c3_Err = FactValue(f).Render(ctx, templ_7745c5c3_Buffer)
12358 if templ_7745c5c3_Err != nil {
12459 return templ_7745c5c3_Err
12560 }
12661 }
127- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</dl>")
62+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</dl>")
12863 if templ_7745c5c3_Err != nil {
12964 return templ_7745c5c3_Err
13065 }
@@ -150,61 +85,61 @@ func CloneBlock(c Clone) templ.Component {
15085 }()
15186 }
15287 ctx = templ.InitializeContext(ctx)
153- templ_7745c5c3_Var7 := templ.GetChildren(ctx)
154- if templ_7745c5c3_Var7 == nil {
155- templ_7745c5c3_Var7 = templ.NopComponent
88+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
89+ if templ_7745c5c3_Var3 == nil {
90+ templ_7745c5c3_Var3 = templ.NopComponent
15691 }
15792 ctx = templ.ClearChildren(ctx)
158- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"clone\" id=\"")
93+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"clone\" id=\"")
15994 if templ_7745c5c3_Err != nil {
16095 return templ_7745c5c3_Err
16196 }
162- var templ_7745c5c3_Var8 string
163- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
97+ var templ_7745c5c3_Var4 string
98+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
16499 if templ_7745c5c3_Err != nil {
165- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 28, Col: 29}
100+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 18, Col: 29}
166101 }
167- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
102+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
168103 if templ_7745c5c3_Err != nil {
169104 return templ_7745c5c3_Err
170105 }
171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")
106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
172107 if templ_7745c5c3_Err != nil {
173108 return templ_7745c5c3_Err
174109 }
175110 for _, line := range c.Lines {
176- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"clone-line\"><span class=\"mute\" aria-hidden=\"true\">$ </span><code>")
111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"clone-line\"><span class=\"mute\" aria-hidden=\"true\">$ </span><code>")
177112 if templ_7745c5c3_Err != nil {
178113 return templ_7745c5c3_Err
179114 }
180- var templ_7745c5c3_Var9 string
181- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(line)
115+ var templ_7745c5c3_Var5 string
116+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(line)
182117 if templ_7745c5c3_Err != nil {
183- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 31, Col: 63}
118+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 21, Col: 63}
184119 }
185- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
120+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
186121 if templ_7745c5c3_Err != nil {
187122 return templ_7745c5c3_Err
188123 }
189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</code> <button type=\"button\" class=\"copy\" data-label=\"[copy]\" aria-label=\"")
124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</code> <button type=\"button\" class=\"copy\" data-label=\"[copy]\" aria-label=\"")
190125 if templ_7745c5c3_Err != nil {
191126 return templ_7745c5c3_Err
192127 }
193- var templ_7745c5c3_Var10 string
194- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("copy: " + line)
128+ var templ_7745c5c3_Var6 string
129+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("copy: " + line)
195130 if templ_7745c5c3_Err != nil {
196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 36, Col: 33}
131+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 26, Col: 33}
197132 }
198- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
133+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
199134 if templ_7745c5c3_Err != nil {
200135 return templ_7745c5c3_Err
201136 }
202- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-on:click=\"navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label\">[copy]</button></div>")
137+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-on:click=\"navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label\">[copy]</button></div>")
203138 if templ_7745c5c3_Err != nil {
204139 return templ_7745c5c3_Err
205140 }
206141 }
207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")
142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
208143 if templ_7745c5c3_Err != nil {
209144 return templ_7745c5c3_Err
210145 }
@@ -229,123 +164,133 @@ func HomeRowView(r HomeRow) templ.Component {
229164 }()
230165 }
231166 ctx = templ.InitializeContext(ctx)
232- templ_7745c5c3_Var11 := templ.GetChildren(ctx)
233- if templ_7745c5c3_Var11 == nil {
234- templ_7745c5c3_Var11 = templ.NopComponent
167+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
168+ if templ_7745c5c3_Var7 == nil {
169+ templ_7745c5c3_Var7 = templ.NopComponent
235170 }
236171 ctx = templ.ClearChildren(ctx)
237- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<tr id=\"")
172+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<tr id=\"")
238173 if templ_7745c5c3_Err != nil {
239174 return templ_7745c5c3_Err
240175 }
241- var templ_7745c5c3_Var12 string
242- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
176+ var templ_7745c5c3_Var8 string
177+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
243178 if templ_7745c5c3_Err != nil {
244- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 46, Col: 14}
179+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 36, Col: 14}
245180 }
246- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
181+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
247182 if templ_7745c5c3_Err != nil {
248183 return templ_7745c5c3_Err
249184 }
250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")
185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"")
251186 if templ_7745c5c3_Err != nil {
252187 return templ_7745c5c3_Err
253188 }
254189 if r.Cursor {
255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " data-cursor")
190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " data-cursor")
256191 if templ_7745c5c3_Err != nil {
257192 return templ_7745c5c3_Err
258193 }
259194 }
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\"><a href=\"")
195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\"><a href=\"")
261196 if templ_7745c5c3_Err != nil {
262197 return templ_7745c5c3_Err
263198 }
264- var templ_7745c5c3_Var13 templ.SafeURL
265- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
199+ var templ_7745c5c3_Var9 templ.SafeURL
200+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
266201 if templ_7745c5c3_Err != nil {
267- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 48, Col: 52}
202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 34}
268203 }
269- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
204+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
270205 if templ_7745c5c3_Err != nil {
271206 return templ_7745c5c3_Err
272207 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")
208+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
274209 if templ_7745c5c3_Err != nil {
275210 return templ_7745c5c3_Err
276211 }
277- var templ_7745c5c3_Var14 string
278- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
212+ var templ_7745c5c3_Var10 string
213+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
279214 if templ_7745c5c3_Err != nil {
280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 48, Col: 63}
215+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 45}
281216 }
282- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
217+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
218+ if templ_7745c5c3_Err != nil {
219+ return templ_7745c5c3_Err
220+ }
221+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a> ")
283222 if templ_7745c5c3_Err != nil {
284223 return templ_7745c5c3_Err
285224 }
286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</a></td><td class=\"c-desc dim\">")
225+ if r.Fresh {
226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"tagged\">● new push</span>")
227+ if templ_7745c5c3_Err != nil {
228+ return templ_7745c5c3_Err
229+ }
230+ }
231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td class=\"c-desc dim\">")
287232 if templ_7745c5c3_Err != nil {
288233 return templ_7745c5c3_Err
289234 }
290- var templ_7745c5c3_Var15 string
291- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
235+ var templ_7745c5c3_Var11 string
236+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
292237 if templ_7745c5c3_Err != nil {
293- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 49, Col: 33}
238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 44, Col: 33}
294239 }
295- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
240+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
296241 if templ_7745c5c3_Err != nil {
297242 return templ_7745c5c3_Err
298243 }
299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"c-when nowrap\"><time")
244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"c-when nowrap\"><time")
300245 if templ_7745c5c3_Err != nil {
301246 return templ_7745c5c3_Err
302247 }
303248 if r.PushedAt != "" {
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " datetime=\"")
249+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " datetime=\"")
305250 if templ_7745c5c3_Err != nil {
306251 return templ_7745c5c3_Err
307252 }
308- var templ_7745c5c3_Var16 string
309- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
253+ var templ_7745c5c3_Var12 string
254+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
310255 if templ_7745c5c3_Err != nil {
311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 53, Col: 26}
256+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 48, Col: 26}
312257 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
258+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
314259 if templ_7745c5c3_Err != nil {
315260 return templ_7745c5c3_Err
316261 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"")
262+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"")
318263 if templ_7745c5c3_Err != nil {
319264 return templ_7745c5c3_Err
320265 }
321266 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">")
267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">")
323268 if templ_7745c5c3_Err != nil {
324269 return templ_7745c5c3_Err
325270 }
326- var templ_7745c5c3_Var17 string
327- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)
271+ var templ_7745c5c3_Var13 string
272+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)
328273 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 14}
274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 50, Col: 14}
330275 }
331- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
276+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
332277 if templ_7745c5c3_Err != nil {
333278 return templ_7745c5c3_Err
334279 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</time></td><td class=\"c-ref\">")
280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</time></td><td class=\"c-ref\">")
336281 if templ_7745c5c3_Err != nil {
337282 return templ_7745c5c3_Err
338283 }
339- var templ_7745c5c3_Var18 string
340- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)
284+ var templ_7745c5c3_Var14 string
285+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)
341286 if templ_7745c5c3_Err != nil {
342- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 30}
287+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 52, Col: 30}
343288 }
344- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
289+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
345290 if templ_7745c5c3_Err != nil {
346291 return templ_7745c5c3_Err
347292 }
348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td></tr>")
293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td></tr>")
349294 if templ_7745c5c3_Err != nil {
350295 return templ_7745c5c3_Err
351296 }
@@ -370,66 +315,66 @@ func Outline(headings []Heading) templ.Component {
370315 }()
371316 }
372317 ctx = templ.InitializeContext(ctx)
373- templ_7745c5c3_Var19 := templ.GetChildren(ctx)
374- if templ_7745c5c3_Var19 == nil {
375- templ_7745c5c3_Var19 = templ.NopComponent
318+ templ_7745c5c3_Var15 := templ.GetChildren(ctx)
319+ if templ_7745c5c3_Var15 == nil {
320+ templ_7745c5c3_Var15 = templ.NopComponent
376321 }
377322 ctx = templ.ClearChildren(ctx)
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<nav class=\"outline\" aria-label=\"Outline\">")
323+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<nav class=\"outline\" aria-label=\"Outline\">")
379324 if templ_7745c5c3_Err != nil {
380325 return templ_7745c5c3_Err
381326 }
382327 for _, h := range headings {
383- var templ_7745c5c3_Var20 = []any{"l" + itoa(h.Level)}
384- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
328+ var templ_7745c5c3_Var16 = []any{"l" + itoa(h.Level)}
329+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
385330 if templ_7745c5c3_Err != nil {
386331 return templ_7745c5c3_Err
387332 }
388- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a class=\"")
333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"")
389334 if templ_7745c5c3_Err != nil {
390335 return templ_7745c5c3_Err
391336 }
392- var templ_7745c5c3_Var21 string
393- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
337+ var templ_7745c5c3_Var17 string
338+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
394339 if templ_7745c5c3_Err != nil {
395340 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
396341 }
397- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
342+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
398343 if templ_7745c5c3_Err != nil {
399344 return templ_7745c5c3_Err
400345 }
401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" href=\"")
346+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" href=\"")
402347 if templ_7745c5c3_Err != nil {
403348 return templ_7745c5c3_Err
404349 }
405- var templ_7745c5c3_Var22 templ.SafeURL
406- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
350+ var templ_7745c5c3_Var18 templ.SafeURL
351+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
407352 if templ_7745c5c3_Err != nil {
408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 68}
353+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 68}
409354 }
410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
355+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
411356 if templ_7745c5c3_Err != nil {
412357 return templ_7745c5c3_Err
413358 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")
359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
415360 if templ_7745c5c3_Err != nil {
416361 return templ_7745c5c3_Err
417362 }
418- var templ_7745c5c3_Var23 string
419- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
363+ var templ_7745c5c3_Var19 string
364+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
420365 if templ_7745c5c3_Err != nil {
421- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 79}
366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 79}
422367 }
423- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
424369 if templ_7745c5c3_Err != nil {
425370 return templ_7745c5c3_Err
426371 }
427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")
372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</a>")
428373 if templ_7745c5c3_Err != nil {
429374 return templ_7745c5c3_Err
430375 }
431376 }
432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</nav>")
377+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</nav>")
433378 if templ_7745c5c3_Err != nil {
434379 return templ_7745c5c3_Err
435380 }
@@ -455,43 +400,43 @@ func ReadmeView(r Readme) templ.Component {
455400 }()
456401 }
457402 ctx = templ.InitializeContext(ctx)
458- templ_7745c5c3_Var24 := templ.GetChildren(ctx)
459- if templ_7745c5c3_Var24 == nil {
460- templ_7745c5c3_Var24 = templ.NopComponent
403+ templ_7745c5c3_Var20 := templ.GetChildren(ctx)
404+ if templ_7745c5c3_Var20 == nil {
405+ templ_7745c5c3_Var20 = templ.NopComponent
461406 }
462407 ctx = templ.ClearChildren(ctx)
463- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<article class=\"prose\" aria-label=\"")
408+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<article class=\"prose\" aria-label=\"")
464409 if templ_7745c5c3_Err != nil {
465410 return templ_7745c5c3_Err
466411 }
467- var templ_7745c5c3_Var25 string
468- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
412+ var templ_7745c5c3_Var21 string
413+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
469414 if templ_7745c5c3_Err != nil {
470- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 43}
415+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 68, Col: 43}
471416 }
472- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
417+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
473418 if templ_7745c5c3_Err != nil {
474419 return templ_7745c5c3_Err
475420 }
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">")
421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
477422 if templ_7745c5c3_Err != nil {
478423 return templ_7745c5c3_Err
479424 }
480425 if r.Note != "" {
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<p class=\"dim\">")
426+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"dim\">")
482427 if templ_7745c5c3_Err != nil {
483428 return templ_7745c5c3_Err
484429 }
485- var templ_7745c5c3_Var26 string
486- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
430+ var templ_7745c5c3_Var22 string
431+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
487432 if templ_7745c5c3_Err != nil {
488- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 75, Col: 26}
433+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 26}
489434 }
490- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
435+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
491436 if templ_7745c5c3_Err != nil {
492437 return templ_7745c5c3_Err
493438 }
494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p>")
439+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</p>")
495440 if templ_7745c5c3_Err != nil {
496441 return templ_7745c5c3_Err
497442 }
@@ -501,7 +446,7 @@ func ReadmeView(r Readme) templ.Component {
501446 return templ_7745c5c3_Err
502447 }
503448 }
504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</article>")
449+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</article>")
505450 if templ_7745c5c3_Err != nil {
506451 return templ_7745c5c3_Err
507452 }
@@ -527,190 +472,190 @@ func CommitRows(rows []CommitRow) templ.Component {
527472 }()
528473 }
529474 ctx = templ.InitializeContext(ctx)
530- templ_7745c5c3_Var27 := templ.GetChildren(ctx)
531- if templ_7745c5c3_Var27 == nil {
532- templ_7745c5c3_Var27 = templ.NopComponent
475+ templ_7745c5c3_Var23 := templ.GetChildren(ctx)
476+ if templ_7745c5c3_Var23 == nil {
477+ templ_7745c5c3_Var23 = templ.NopComponent
533478 }
534479 ctx = templ.ClearChildren(ctx)
535- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
536481 if templ_7745c5c3_Err != nil {
537482 return templ_7745c5c3_Err
538483 }
539484 for _, c := range rows {
540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<tr")
485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<tr")
541486 if templ_7745c5c3_Err != nil {
542487 return templ_7745c5c3_Err
543488 }
544489 if c.ID != "" {
545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " id=\"")
490+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " id=\"")
546491 if templ_7745c5c3_Err != nil {
547492 return templ_7745c5c3_Err
548493 }
549- var templ_7745c5c3_Var28 string
550- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
494+ var templ_7745c5c3_Var24 string
495+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
551496 if templ_7745c5c3_Err != nil {
552- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 15}
497+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 85, Col: 15}
553498 }
554- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
499+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
555500 if templ_7745c5c3_Err != nil {
556501 return templ_7745c5c3_Err
557502 }
558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"")
503+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"")
559504 if templ_7745c5c3_Err != nil {
560505 return templ_7745c5c3_Err
561506 }
562507 }
563508 if c.Cursor {
564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " data-cursor")
509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " data-cursor")
565510 if templ_7745c5c3_Err != nil {
566511 return templ_7745c5c3_Err
567512 }
568513 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
570515 if templ_7745c5c3_Err != nil {
571516 return templ_7745c5c3_Err
572517 }
573- var templ_7745c5c3_Var29 string
574- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
518+ var templ_7745c5c3_Var25 string
519+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
575520 if templ_7745c5c3_Err != nil {
576- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 37}
521+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 37}
577522 }
578- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
579524 if templ_7745c5c3_Err != nil {
580525 return templ_7745c5c3_Err
581526 }
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</td><td class=\"c-subject\"><a href=\"")
527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td class=\"c-subject\"><a href=\"")
583528 if templ_7745c5c3_Err != nil {
584529 return templ_7745c5c3_Err
585530 }
586- var templ_7745c5c3_Var30 templ.SafeURL
587- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
531+ var templ_7745c5c3_Var26 templ.SafeURL
532+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
588533 if templ_7745c5c3_Err != nil {
589- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 38}
534+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 92, Col: 38}
590535 }
591- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
536+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
592537 if templ_7745c5c3_Err != nil {
593538 return templ_7745c5c3_Err
594539 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">")
540+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">")
596541 if templ_7745c5c3_Err != nil {
597542 return templ_7745c5c3_Err
598543 }
599- var templ_7745c5c3_Var31 string
600- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
544+ var templ_7745c5c3_Var27 string
545+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
601546 if templ_7745c5c3_Err != nil {
602- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 52}
547+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 92, Col: 52}
603548 }
604- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
549+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
605550 if templ_7745c5c3_Err != nil {
606551 return templ_7745c5c3_Err
607552 }
608- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> ")
553+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
609554 if templ_7745c5c3_Err != nil {
610555 return templ_7745c5c3_Err
611556 }
612557 if c.Merge {
613- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"tagged\">(merge)</span> ")
558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"tagged\">(merge)</span> ")
614559 if templ_7745c5c3_Err != nil {
615560 return templ_7745c5c3_Err
616561 }
617562 }
618563 for _, b := range c.Branches {
619- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"tagged\">[")
564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"tagged\">[")
620565 if templ_7745c5c3_Err != nil {
621566 return templ_7745c5c3_Err
622567 }
623- var templ_7745c5c3_Var32 string
624- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(b)
568+ var templ_7745c5c3_Var28 string
569+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(b)
625570 if templ_7745c5c3_Err != nil {
626- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 102, Col: 32}
571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 32}
627572 }
628- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
573+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
629574 if templ_7745c5c3_Err != nil {
630575 return templ_7745c5c3_Err
631576 }
632- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "]</span> ")
577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "]</span> ")
633578 if templ_7745c5c3_Err != nil {
634579 return templ_7745c5c3_Err
635580 }
636581 }
637582 for _, t := range c.Tags {
638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"tagged\">[tag: ")
583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"tagged\">[tag: ")
639584 if templ_7745c5c3_Err != nil {
640585 return templ_7745c5c3_Err
641586 }
642- var templ_7745c5c3_Var33 string
643- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(t)
587+ var templ_7745c5c3_Var29 string
588+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(t)
644589 if templ_7745c5c3_Err != nil {
645- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 105, Col: 37}
590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 100, Col: 37}
646591 }
647- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
592+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
648593 if templ_7745c5c3_Err != nil {
649594 return templ_7745c5c3_Err
650595 }
651- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "]</span>")
596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "]</span>")
652597 if templ_7745c5c3_Err != nil {
653598 return templ_7745c5c3_Err
654599 }
655600 }
656- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-who dim\">")
601+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-who dim\">")
657602 if templ_7745c5c3_Err != nil {
658603 return templ_7745c5c3_Err
659604 }
660- var templ_7745c5c3_Var34 string
661- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
605+ var templ_7745c5c3_Var30 string
606+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
662607 if templ_7745c5c3_Err != nil {
663- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 37}
608+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 37}
664609 }
665- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
610+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
666611 if templ_7745c5c3_Err != nil {
667612 return templ_7745c5c3_Err
668613 }
669- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
614+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
670615 if templ_7745c5c3_Err != nil {
671616 return templ_7745c5c3_Err
672617 }
673- var templ_7745c5c3_Var35 string
674- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
618+ var templ_7745c5c3_Var31 string
619+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
675620 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 109, Col: 60}
621+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 60}
677622 }
678- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
623+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
679624 if templ_7745c5c3_Err != nil {
680625 return templ_7745c5c3_Err
681626 }
682- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" title=\"")
627+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" title=\"")
683628 if templ_7745c5c3_Err != nil {
684629 return templ_7745c5c3_Err
685630 }
686- var templ_7745c5c3_Var36 string
687- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
631+ var templ_7745c5c3_Var32 string
632+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
688633 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 109, Col: 79}
634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 79}
690635 }
691- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
636+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
692637 if templ_7745c5c3_Err != nil {
693638 return templ_7745c5c3_Err
694639 }
695- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")
640+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
696641 if templ_7745c5c3_Err != nil {
697642 return templ_7745c5c3_Err
698643 }
699- var templ_7745c5c3_Var37 string
700- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
644+ var templ_7745c5c3_Var33 string
645+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
701646 if templ_7745c5c3_Err != nil {
702- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 109, Col: 90}
647+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 90}
703648 }
704- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
649+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
705650 if templ_7745c5c3_Err != nil {
706651 return templ_7745c5c3_Err
707652 }
708- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</time></td></tr>")
653+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</time></td></tr>")
709654 if templ_7745c5c3_Err != nil {
710655 return templ_7745c5c3_Err
711656 }
712657 }
713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</tbody></table>")
658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</tbody></table>")
714659 if templ_7745c5c3_Err != nil {
715660 return templ_7745c5c3_Err
716661 }
@@ -50,81 +50,16 @@ func Facts(facts []Fact) templ.Component {
50 if templ_7745c5c3_Err != nil {50 if templ_7745c5c3_Err != nil {
51 return templ_7745c5c3_Err51 return templ_7745c5c3_Err
52 }52 }
53- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</dt><dd")53+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</dt>")
54 if templ_7745c5c3_Err != nil {54 if templ_7745c5c3_Err != nil {
55 return templ_7745c5c3_Err55 return templ_7745c5c3_Err
56 }56 }
57- if f.ID != "" {57+ templ_7745c5c3_Err = FactValue(f).Render(ctx, templ_7745c5c3_Buffer)
58- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, " id=\"")
59- if templ_7745c5c3_Err != nil {
60- return templ_7745c5c3_Err
61- }
62- var templ_7745c5c3_Var3 string
63- templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.ID)
64- if templ_7745c5c3_Err != nil {
65- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 12, Col: 14}
66- }
67- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
68- if templ_7745c5c3_Err != nil {
69- return templ_7745c5c3_Err
70- }
71- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\"")
72- if templ_7745c5c3_Err != nil {
73- return templ_7745c5c3_Err
74- }
75- }
76- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ">")
77- if templ_7745c5c3_Err != nil {
78- return templ_7745c5c3_Err
79- }
80- if f.Time != "" {
81- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<time datetime=\"")
82- if templ_7745c5c3_Err != nil {
83- return templ_7745c5c3_Err
84- }
85- var templ_7745c5c3_Var4 string
86- templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(f.Time)
87- if templ_7745c5c3_Err != nil {
88- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 16, Col: 28}
89- }
90- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
91- if templ_7745c5c3_Err != nil {
92- return templ_7745c5c3_Err
93- }
94- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
95- if templ_7745c5c3_Err != nil {
96- return templ_7745c5c3_Err
97- }
98- var templ_7745c5c3_Var5 string
99- templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(f.Val)
100- if templ_7745c5c3_Err != nil {
101- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 16, Col: 38}
102- }
103- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
104- if templ_7745c5c3_Err != nil {
105- return templ_7745c5c3_Err
106- }
107- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</time>")
108- if templ_7745c5c3_Err != nil {
109- return templ_7745c5c3_Err
110- }
111- } else {
112- var templ_7745c5c3_Var6 string
113- templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(f.Val)
114- if templ_7745c5c3_Err != nil {
115- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 18, Col: 12}
116- }
117- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
118- if templ_7745c5c3_Err != nil {
119- return templ_7745c5c3_Err
120- }
121- }
122- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</dd>")
123 if templ_7745c5c3_Err != nil {58 if templ_7745c5c3_Err != nil {
124 return templ_7745c5c3_Err59 return templ_7745c5c3_Err
125 }60 }
126 }61 }
127- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</dl>")62+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</dl>")
128 if templ_7745c5c3_Err != nil {63 if templ_7745c5c3_Err != nil {
129 return templ_7745c5c3_Err64 return templ_7745c5c3_Err
130 }65 }
@@ -150,61 +85,61 @@ func CloneBlock(c Clone) templ.Component {
150 }()85 }()
151 }86 }
152 ctx = templ.InitializeContext(ctx)87 ctx = templ.InitializeContext(ctx)
153- templ_7745c5c3_Var7 := templ.GetChildren(ctx)88+ templ_7745c5c3_Var3 := templ.GetChildren(ctx)
154- if templ_7745c5c3_Var7 == nil {89+ if templ_7745c5c3_Var3 == nil {
155- templ_7745c5c3_Var7 = templ.NopComponent90+ templ_7745c5c3_Var3 = templ.NopComponent
156 }91 }
157 ctx = templ.ClearChildren(ctx)92 ctx = templ.ClearChildren(ctx)
158- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"clone\" id=\"")93+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<div class=\"clone\" id=\"")
159 if templ_7745c5c3_Err != nil {94 if templ_7745c5c3_Err != nil {
160 return templ_7745c5c3_Err95 return templ_7745c5c3_Err
161 }96 }
162- var templ_7745c5c3_Var8 string97+ var templ_7745c5c3_Var4 string
163- templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)98+ templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
164 if templ_7745c5c3_Err != nil {99 if templ_7745c5c3_Err != nil {
165- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 28, Col: 29}100+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 18, Col: 29}
166 }101 }
167- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)102+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
168 if templ_7745c5c3_Err != nil {103 if templ_7745c5c3_Err != nil {
169 return templ_7745c5c3_Err104 return templ_7745c5c3_Err
170 }105 }
171- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\">")106+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
172 if templ_7745c5c3_Err != nil {107 if templ_7745c5c3_Err != nil {
173 return templ_7745c5c3_Err108 return templ_7745c5c3_Err
174 }109 }
175 for _, line := range c.Lines {110 for _, line := range c.Lines {
176- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"clone-line\"><span class=\"mute\" aria-hidden=\"true\">$ </span><code>")111+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<div class=\"clone-line\"><span class=\"mute\" aria-hidden=\"true\">$ </span><code>")
177 if templ_7745c5c3_Err != nil {112 if templ_7745c5c3_Err != nil {
178 return templ_7745c5c3_Err113 return templ_7745c5c3_Err
179 }114 }
180- var templ_7745c5c3_Var9 string115+ var templ_7745c5c3_Var5 string
181- templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(line)116+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(line)
182 if templ_7745c5c3_Err != nil {117 if templ_7745c5c3_Err != nil {
183- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 31, Col: 63}118+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 21, Col: 63}
184 }119 }
185- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))120+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
186 if templ_7745c5c3_Err != nil {121 if templ_7745c5c3_Err != nil {
187 return templ_7745c5c3_Err122 return templ_7745c5c3_Err
188 }123 }
189- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</code> <button type=\"button\" class=\"copy\" data-label=\"[copy]\" aria-label=\"")124+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</code> <button type=\"button\" class=\"copy\" data-label=\"[copy]\" aria-label=\"")
190 if templ_7745c5c3_Err != nil {125 if templ_7745c5c3_Err != nil {
191 return templ_7745c5c3_Err126 return templ_7745c5c3_Err
192 }127 }
193- var templ_7745c5c3_Var10 string128+ var templ_7745c5c3_Var6 string
194- templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("copy: " + line)129+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("copy: " + line)
195 if templ_7745c5c3_Err != nil {130 if templ_7745c5c3_Err != nil {
196- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 36, Col: 33}131+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 26, Col: 33}
197 }132 }
198- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)133+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
199 if templ_7745c5c3_Err != nil {134 if templ_7745c5c3_Err != nil {
200 return templ_7745c5c3_Err135 return templ_7745c5c3_Err
201 }136 }
202- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-on:click=\"navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label\">[copy]</button></div>")137+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" hx-on:click=\"navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label\">[copy]</button></div>")
203 if templ_7745c5c3_Err != nil {138 if templ_7745c5c3_Err != nil {
204 return templ_7745c5c3_Err139 return templ_7745c5c3_Err
205 }140 }
206 }141 }
207- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</div>")142+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
208 if templ_7745c5c3_Err != nil {143 if templ_7745c5c3_Err != nil {
209 return templ_7745c5c3_Err144 return templ_7745c5c3_Err
210 }145 }
@@ -229,123 +164,133 @@ func HomeRowView(r HomeRow) templ.Component {
229 }()164 }()
230 }165 }
231 ctx = templ.InitializeContext(ctx)166 ctx = templ.InitializeContext(ctx)
232- templ_7745c5c3_Var11 := templ.GetChildren(ctx)167+ templ_7745c5c3_Var7 := templ.GetChildren(ctx)
233- if templ_7745c5c3_Var11 == nil {168+ if templ_7745c5c3_Var7 == nil {
234- templ_7745c5c3_Var11 = templ.NopComponent169+ templ_7745c5c3_Var7 = templ.NopComponent
235 }170 }
236 ctx = templ.ClearChildren(ctx)171 ctx = templ.ClearChildren(ctx)
237- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<tr id=\"")172+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<tr id=\"")
238 if templ_7745c5c3_Err != nil {173 if templ_7745c5c3_Err != nil {
239 return templ_7745c5c3_Err174 return templ_7745c5c3_Err
240 }175 }
241- var templ_7745c5c3_Var12 string176+ var templ_7745c5c3_Var8 string
242- templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)177+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.ID)
243 if templ_7745c5c3_Err != nil {178 if templ_7745c5c3_Err != nil {
244- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 46, Col: 14}179+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 36, Col: 14}
245 }180 }
246- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)181+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
247 if templ_7745c5c3_Err != nil {182 if templ_7745c5c3_Err != nil {
248 return templ_7745c5c3_Err183 return templ_7745c5c3_Err
249 }184 }
250- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\"")185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\"")
251 if templ_7745c5c3_Err != nil {186 if templ_7745c5c3_Err != nil {
252 return templ_7745c5c3_Err187 return templ_7745c5c3_Err
253 }188 }
254 if r.Cursor {189 if r.Cursor {
255- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " data-cursor")190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " data-cursor")
256 if templ_7745c5c3_Err != nil {191 if templ_7745c5c3_Err != nil {
257 return templ_7745c5c3_Err192 return templ_7745c5c3_Err
258 }193 }
259 }194 }
260- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\"><a href=\"")195+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-name\"><a href=\"")
261 if templ_7745c5c3_Err != nil {196 if templ_7745c5c3_Err != nil {
262 return templ_7745c5c3_Err197 return templ_7745c5c3_Err
263 }198 }
264- var templ_7745c5c3_Var13 templ.SafeURL199+ var templ_7745c5c3_Var9 templ.SafeURL
265- templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))200+ templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href))
266 if templ_7745c5c3_Err != nil {201 if templ_7745c5c3_Err != nil {
267- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 48, Col: 52}202+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 34}
268 }203 }
269- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))204+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
270 if templ_7745c5c3_Err != nil {205 if templ_7745c5c3_Err != nil {
271 return templ_7745c5c3_Err206 return templ_7745c5c3_Err
272 }207 }
273- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">")208+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "\">")
274 if templ_7745c5c3_Err != nil {209 if templ_7745c5c3_Err != nil {
275 return templ_7745c5c3_Err210 return templ_7745c5c3_Err
276 }211 }
277- var templ_7745c5c3_Var14 string212+ var templ_7745c5c3_Var10 string
278- templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)213+ templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
279 if templ_7745c5c3_Err != nil {214 if templ_7745c5c3_Err != nil {
280- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 48, Col: 63}215+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 39, Col: 45}
281 }216 }
282- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))217+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
218+ if templ_7745c5c3_Err != nil {
219+ return templ_7745c5c3_Err
220+ }
221+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</a> ")
283 if templ_7745c5c3_Err != nil {222 if templ_7745c5c3_Err != nil {
284 return templ_7745c5c3_Err223 return templ_7745c5c3_Err
285 }224 }
286- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</a></td><td class=\"c-desc dim\">")225+ if r.Fresh {
226+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<span class=\"tagged\">● new push</span>")
227+ if templ_7745c5c3_Err != nil {
228+ return templ_7745c5c3_Err
229+ }
230+ }
231+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</td><td class=\"c-desc dim\">")
287 if templ_7745c5c3_Err != nil {232 if templ_7745c5c3_Err != nil {
288 return templ_7745c5c3_Err233 return templ_7745c5c3_Err
289 }234 }
290- var templ_7745c5c3_Var15 string235+ var templ_7745c5c3_Var11 string
291- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)236+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(r.Desc)
292 if templ_7745c5c3_Err != nil {237 if templ_7745c5c3_Err != nil {
293- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 49, Col: 33}238+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 44, Col: 33}
294 }239 }
295- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))240+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
296 if templ_7745c5c3_Err != nil {241 if templ_7745c5c3_Err != nil {
297 return templ_7745c5c3_Err242 return templ_7745c5c3_Err
298 }243 }
299- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td><td class=\"c-when nowrap\"><time")244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</td><td class=\"c-when nowrap\"><time")
300 if templ_7745c5c3_Err != nil {245 if templ_7745c5c3_Err != nil {
301 return templ_7745c5c3_Err246 return templ_7745c5c3_Err
302 }247 }
303 if r.PushedAt != "" {248 if r.PushedAt != "" {
304- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " datetime=\"")249+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " datetime=\"")
305 if templ_7745c5c3_Err != nil {250 if templ_7745c5c3_Err != nil {
306 return templ_7745c5c3_Err251 return templ_7745c5c3_Err
307 }252 }
308- var templ_7745c5c3_Var16 string253+ var templ_7745c5c3_Var12 string
309- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)254+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.PushedAt)
310 if templ_7745c5c3_Err != nil {255 if templ_7745c5c3_Err != nil {
311- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 53, Col: 26}256+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 48, Col: 26}
312 }257 }
313- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)258+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
314 if templ_7745c5c3_Err != nil {259 if templ_7745c5c3_Err != nil {
315 return templ_7745c5c3_Err260 return templ_7745c5c3_Err
316 }261 }
317- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"")262+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\"")
318 if templ_7745c5c3_Err != nil {263 if templ_7745c5c3_Err != nil {
319 return templ_7745c5c3_Err264 return templ_7745c5c3_Err
320 }265 }
321 }266 }
322- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, ">")267+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, ">")
323 if templ_7745c5c3_Err != nil {268 if templ_7745c5c3_Err != nil {
324 return templ_7745c5c3_Err269 return templ_7745c5c3_Err
325 }270 }
326- var templ_7745c5c3_Var17 string271+ var templ_7745c5c3_Var13 string
327- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)272+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Pushed)
328 if templ_7745c5c3_Err != nil {273 if templ_7745c5c3_Err != nil {
329- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 55, Col: 14}274+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 50, Col: 14}
330 }275 }
331- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))276+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
332 if templ_7745c5c3_Err != nil {277 if templ_7745c5c3_Err != nil {
333 return templ_7745c5c3_Err278 return templ_7745c5c3_Err
334 }279 }
335- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</time></td><td class=\"c-ref\">")280+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "</time></td><td class=\"c-ref\">")
336 if templ_7745c5c3_Err != nil {281 if templ_7745c5c3_Err != nil {
337 return templ_7745c5c3_Err282 return templ_7745c5c3_Err
338 }283 }
339- var templ_7745c5c3_Var18 string284+ var templ_7745c5c3_Var14 string
340- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)285+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(r.Branch)
341 if templ_7745c5c3_Err != nil {286 if templ_7745c5c3_Err != nil {
342- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 57, Col: 30}287+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 52, Col: 30}
343 }288 }
344- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))289+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
345 if templ_7745c5c3_Err != nil {290 if templ_7745c5c3_Err != nil {
346 return templ_7745c5c3_Err291 return templ_7745c5c3_Err
347 }292 }
348- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</td></tr>")293+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</td></tr>")
349 if templ_7745c5c3_Err != nil {294 if templ_7745c5c3_Err != nil {
350 return templ_7745c5c3_Err295 return templ_7745c5c3_Err
351 }296 }
@@ -370,66 +315,66 @@ func Outline(headings []Heading) templ.Component {
370 }()315 }()
371 }316 }
372 ctx = templ.InitializeContext(ctx)317 ctx = templ.InitializeContext(ctx)
373- templ_7745c5c3_Var19 := templ.GetChildren(ctx)318+ templ_7745c5c3_Var15 := templ.GetChildren(ctx)
374- if templ_7745c5c3_Var19 == nil {319+ if templ_7745c5c3_Var15 == nil {
375- templ_7745c5c3_Var19 = templ.NopComponent320+ templ_7745c5c3_Var15 = templ.NopComponent
376 }321 }
377 ctx = templ.ClearChildren(ctx)322 ctx = templ.ClearChildren(ctx)
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<nav class=\"outline\" aria-label=\"Outline\">")323+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<nav class=\"outline\" aria-label=\"Outline\">")
379 if templ_7745c5c3_Err != nil {324 if templ_7745c5c3_Err != nil {
380 return templ_7745c5c3_Err325 return templ_7745c5c3_Err
381 }326 }
382 for _, h := range headings {327 for _, h := range headings {
383- var templ_7745c5c3_Var20 = []any{"l" + itoa(h.Level)}328+ var templ_7745c5c3_Var16 = []any{"l" + itoa(h.Level)}
384- templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)329+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var16...)
385 if templ_7745c5c3_Err != nil {330 if templ_7745c5c3_Err != nil {
386 return templ_7745c5c3_Err331 return templ_7745c5c3_Err
387 }332 }
388- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a class=\"")333+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<a class=\"")
389 if templ_7745c5c3_Err != nil {334 if templ_7745c5c3_Err != nil {
390 return templ_7745c5c3_Err335 return templ_7745c5c3_Err
391 }336 }
392- var templ_7745c5c3_Var21 string337+ var templ_7745c5c3_Var17 string
393- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())338+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
394 if templ_7745c5c3_Err != nil {339 if templ_7745c5c3_Err != nil {
395 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}340 return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 1, Col: 0}
396 }341 }
397- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)342+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
398 if templ_7745c5c3_Err != nil {343 if templ_7745c5c3_Err != nil {
399 return templ_7745c5c3_Err344 return templ_7745c5c3_Err
400 }345 }
401- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" href=\"")346+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" href=\"")
402 if templ_7745c5c3_Err != nil {347 if templ_7745c5c3_Err != nil {
403 return templ_7745c5c3_Err348 return templ_7745c5c3_Err
404 }349 }
405- var templ_7745c5c3_Var22 templ.SafeURL350+ var templ_7745c5c3_Var18 templ.SafeURL
406- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))351+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("#" + h.ID))
407 if templ_7745c5c3_Err != nil {352 if templ_7745c5c3_Err != nil {
408- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 68}353+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 68}
409 }354 }
410- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))355+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
411 if templ_7745c5c3_Err != nil {356 if templ_7745c5c3_Err != nil {
412 return templ_7745c5c3_Err357 return templ_7745c5c3_Err
413 }358 }
414- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\">")359+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
415 if templ_7745c5c3_Err != nil {360 if templ_7745c5c3_Err != nil {
416 return templ_7745c5c3_Err361 return templ_7745c5c3_Err
417 }362 }
418- var templ_7745c5c3_Var23 string363+ var templ_7745c5c3_Var19 string
419- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)364+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(h.Text)
420 if templ_7745c5c3_Err != nil {365 if templ_7745c5c3_Err != nil {
421- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 65, Col: 79}366+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 60, Col: 79}
422 }367 }
423- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))368+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
424 if templ_7745c5c3_Err != nil {369 if templ_7745c5c3_Err != nil {
425 return templ_7745c5c3_Err370 return templ_7745c5c3_Err
426 }371 }
427- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</a>")372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</a>")
428 if templ_7745c5c3_Err != nil {373 if templ_7745c5c3_Err != nil {
429 return templ_7745c5c3_Err374 return templ_7745c5c3_Err
430 }375 }
431 }376 }
432- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</nav>")377+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</nav>")
433 if templ_7745c5c3_Err != nil {378 if templ_7745c5c3_Err != nil {
434 return templ_7745c5c3_Err379 return templ_7745c5c3_Err
435 }380 }
@@ -455,43 +400,43 @@ func ReadmeView(r Readme) templ.Component {
455 }()400 }()
456 }401 }
457 ctx = templ.InitializeContext(ctx)402 ctx = templ.InitializeContext(ctx)
458- templ_7745c5c3_Var24 := templ.GetChildren(ctx)403+ templ_7745c5c3_Var20 := templ.GetChildren(ctx)
459- if templ_7745c5c3_Var24 == nil {404+ if templ_7745c5c3_Var20 == nil {
460- templ_7745c5c3_Var24 = templ.NopComponent405+ templ_7745c5c3_Var20 = templ.NopComponent
461 }406 }
462 ctx = templ.ClearChildren(ctx)407 ctx = templ.ClearChildren(ctx)
463- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<article class=\"prose\" aria-label=\"")408+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<article class=\"prose\" aria-label=\"")
464 if templ_7745c5c3_Err != nil {409 if templ_7745c5c3_Err != nil {
465 return templ_7745c5c3_Err410 return templ_7745c5c3_Err
466 }411 }
467- var templ_7745c5c3_Var25 string412+ var templ_7745c5c3_Var21 string
468- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)413+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
469 if templ_7745c5c3_Err != nil {414 if templ_7745c5c3_Err != nil {
470- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 73, Col: 43}415+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 68, Col: 43}
471 }416 }
472- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)417+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
473 if templ_7745c5c3_Err != nil {418 if templ_7745c5c3_Err != nil {
474 return templ_7745c5c3_Err419 return templ_7745c5c3_Err
475 }420 }
476- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">")421+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\">")
477 if templ_7745c5c3_Err != nil {422 if templ_7745c5c3_Err != nil {
478 return templ_7745c5c3_Err423 return templ_7745c5c3_Err
479 }424 }
480 if r.Note != "" {425 if r.Note != "" {
481- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<p class=\"dim\">")426+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<p class=\"dim\">")
482 if templ_7745c5c3_Err != nil {427 if templ_7745c5c3_Err != nil {
483 return templ_7745c5c3_Err428 return templ_7745c5c3_Err
484 }429 }
485- var templ_7745c5c3_Var26 string430+ var templ_7745c5c3_Var22 string
486- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)431+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(r.Note)
487 if templ_7745c5c3_Err != nil {432 if templ_7745c5c3_Err != nil {
488- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 75, Col: 26}433+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 70, Col: 26}
489 }434 }
490- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))435+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
491 if templ_7745c5c3_Err != nil {436 if templ_7745c5c3_Err != nil {
492 return templ_7745c5c3_Err437 return templ_7745c5c3_Err
493 }438 }
494- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p>")439+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</p>")
495 if templ_7745c5c3_Err != nil {440 if templ_7745c5c3_Err != nil {
496 return templ_7745c5c3_Err441 return templ_7745c5c3_Err
497 }442 }
@@ -501,7 +446,7 @@ func ReadmeView(r Readme) templ.Component {
501 return templ_7745c5c3_Err446 return templ_7745c5c3_Err
502 }447 }
503 }448 }
504- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</article>")449+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</article>")
505 if templ_7745c5c3_Err != nil {450 if templ_7745c5c3_Err != nil {
506 return templ_7745c5c3_Err451 return templ_7745c5c3_Err
507 }452 }
@@ -527,190 +472,190 @@ func CommitRows(rows []CommitRow) templ.Component {
527 }()472 }()
528 }473 }
529 ctx = templ.InitializeContext(ctx)474 ctx = templ.InitializeContext(ctx)
530- templ_7745c5c3_Var27 := templ.GetChildren(ctx)475+ templ_7745c5c3_Var23 := templ.GetChildren(ctx)
531- if templ_7745c5c3_Var27 == nil {476+ if templ_7745c5c3_Var23 == nil {
532- templ_7745c5c3_Var27 = templ.NopComponent477+ templ_7745c5c3_Var23 = templ.NopComponent
533 }478 }
534 ctx = templ.ClearChildren(ctx)479 ctx = templ.ClearChildren(ctx)
535- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")480+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<table class=\"list commits\" aria-label=\"Commits\"><tbody data-rows>")
536 if templ_7745c5c3_Err != nil {481 if templ_7745c5c3_Err != nil {
537 return templ_7745c5c3_Err482 return templ_7745c5c3_Err
538 }483 }
539 for _, c := range rows {484 for _, c := range rows {
540- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<tr")485+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<tr")
541 if templ_7745c5c3_Err != nil {486 if templ_7745c5c3_Err != nil {
542 return templ_7745c5c3_Err487 return templ_7745c5c3_Err
543 }488 }
544 if c.ID != "" {489 if c.ID != "" {
545- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " id=\"")490+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, " id=\"")
546 if templ_7745c5c3_Err != nil {491 if templ_7745c5c3_Err != nil {
547 return templ_7745c5c3_Err492 return templ_7745c5c3_Err
548 }493 }
549- var templ_7745c5c3_Var28 string494+ var templ_7745c5c3_Var24 string
550- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)495+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.ID)
551 if templ_7745c5c3_Err != nil {496 if templ_7745c5c3_Err != nil {
552- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 15}497+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 85, Col: 15}
553 }498 }
554- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)499+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
555 if templ_7745c5c3_Err != nil {500 if templ_7745c5c3_Err != nil {
556 return templ_7745c5c3_Err501 return templ_7745c5c3_Err
557 }502 }
558- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\"")503+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\"")
559 if templ_7745c5c3_Err != nil {504 if templ_7745c5c3_Err != nil {
560 return templ_7745c5c3_Err505 return templ_7745c5c3_Err
561 }506 }
562 }507 }
563 if c.Cursor {508 if c.Cursor {
564- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, " data-cursor")509+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " data-cursor")
565 if templ_7745c5c3_Err != nil {510 if templ_7745c5c3_Err != nil {
566 return templ_7745c5c3_Err511 return templ_7745c5c3_Err
567 }512 }
568 }513 }
569- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")514+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-sha mute\">")
570 if templ_7745c5c3_Err != nil {515 if templ_7745c5c3_Err != nil {
571 return templ_7745c5c3_Err516 return templ_7745c5c3_Err
572 }517 }
573- var templ_7745c5c3_Var29 string518+ var templ_7745c5c3_Var25 string
574- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)519+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(c.Short)
575 if templ_7745c5c3_Err != nil {520 if templ_7745c5c3_Err != nil {
576- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 95, Col: 37}521+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 90, Col: 37}
577 }522 }
578- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))523+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
579 if templ_7745c5c3_Err != nil {524 if templ_7745c5c3_Err != nil {
580 return templ_7745c5c3_Err525 return templ_7745c5c3_Err
581 }526 }
582- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</td><td class=\"c-subject\"><a href=\"")527+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</td><td class=\"c-subject\"><a href=\"")
583 if templ_7745c5c3_Err != nil {528 if templ_7745c5c3_Err != nil {
584 return templ_7745c5c3_Err529 return templ_7745c5c3_Err
585 }530 }
586- var templ_7745c5c3_Var30 templ.SafeURL531+ var templ_7745c5c3_Var26 templ.SafeURL
587- templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))532+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(c.CHref))
588 if templ_7745c5c3_Err != nil {533 if templ_7745c5c3_Err != nil {
589- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 38}534+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 92, Col: 38}
590 }535 }
591- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))536+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
592 if templ_7745c5c3_Err != nil {537 if templ_7745c5c3_Err != nil {
593 return templ_7745c5c3_Err538 return templ_7745c5c3_Err
594 }539 }
595- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">")540+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\">")
596 if templ_7745c5c3_Err != nil {541 if templ_7745c5c3_Err != nil {
597 return templ_7745c5c3_Err542 return templ_7745c5c3_Err
598 }543 }
599- var templ_7745c5c3_Var31 string544+ var templ_7745c5c3_Var27 string
600- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)545+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(c.Subject)
601 if templ_7745c5c3_Err != nil {546 if templ_7745c5c3_Err != nil {
602- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 52}547+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 92, Col: 52}
603 }548 }
604- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))549+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
605 if templ_7745c5c3_Err != nil {550 if templ_7745c5c3_Err != nil {
606 return templ_7745c5c3_Err551 return templ_7745c5c3_Err
607 }552 }
608- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</a> ")553+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</a> ")
609 if templ_7745c5c3_Err != nil {554 if templ_7745c5c3_Err != nil {
610 return templ_7745c5c3_Err555 return templ_7745c5c3_Err
611 }556 }
612 if c.Merge {557 if c.Merge {
613- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<span class=\"tagged\">(merge)</span> ")558+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span class=\"tagged\">(merge)</span> ")
614 if templ_7745c5c3_Err != nil {559 if templ_7745c5c3_Err != nil {
615 return templ_7745c5c3_Err560 return templ_7745c5c3_Err
616 }561 }
617 }562 }
618 for _, b := range c.Branches {563 for _, b := range c.Branches {
619- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"tagged\">[")564+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<span class=\"tagged\">[")
620 if templ_7745c5c3_Err != nil {565 if templ_7745c5c3_Err != nil {
621 return templ_7745c5c3_Err566 return templ_7745c5c3_Err
622 }567 }
623- var templ_7745c5c3_Var32 string568+ var templ_7745c5c3_Var28 string
624- templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(b)569+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(b)
625 if templ_7745c5c3_Err != nil {570 if templ_7745c5c3_Err != nil {
626- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 102, Col: 32}571+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 97, Col: 32}
627 }572 }
628- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))573+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
629 if templ_7745c5c3_Err != nil {574 if templ_7745c5c3_Err != nil {
630 return templ_7745c5c3_Err575 return templ_7745c5c3_Err
631 }576 }
632- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "]</span> ")577+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "]</span> ")
633 if templ_7745c5c3_Err != nil {578 if templ_7745c5c3_Err != nil {
634 return templ_7745c5c3_Err579 return templ_7745c5c3_Err
635 }580 }
636 }581 }
637 for _, t := range c.Tags {582 for _, t := range c.Tags {
638- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<span class=\"tagged\">[tag: ")583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<span class=\"tagged\">[tag: ")
639 if templ_7745c5c3_Err != nil {584 if templ_7745c5c3_Err != nil {
640 return templ_7745c5c3_Err585 return templ_7745c5c3_Err
641 }586 }
642- var templ_7745c5c3_Var33 string587+ var templ_7745c5c3_Var29 string
643- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(t)588+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(t)
644 if templ_7745c5c3_Err != nil {589 if templ_7745c5c3_Err != nil {
645- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 105, Col: 37}590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 100, Col: 37}
646 }591 }
647- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))592+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
648 if templ_7745c5c3_Err != nil {593 if templ_7745c5c3_Err != nil {
649 return templ_7745c5c3_Err594 return templ_7745c5c3_Err
650 }595 }
651- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "]</span>")596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "]</span>")
652 if templ_7745c5c3_Err != nil {597 if templ_7745c5c3_Err != nil {
653 return templ_7745c5c3_Err598 return templ_7745c5c3_Err
654 }599 }
655 }600 }
656- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</td><td class=\"c-who dim\">")601+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</td><td class=\"c-who dim\">")
657 if templ_7745c5c3_Err != nil {602 if templ_7745c5c3_Err != nil {
658 return templ_7745c5c3_Err603 return templ_7745c5c3_Err
659 }604 }
660- var templ_7745c5c3_Var34 string605+ var templ_7745c5c3_Var30 string
661- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)606+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(c.Author)
662 if templ_7745c5c3_Err != nil {607 if templ_7745c5c3_Err != nil {
663- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 108, Col: 37}608+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 103, Col: 37}
664 }609 }
665- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))610+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
666 if templ_7745c5c3_Err != nil {611 if templ_7745c5c3_Err != nil {
667 return templ_7745c5c3_Err612 return templ_7745c5c3_Err
668 }613 }
669- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")614+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</td><td class=\"c-when nowrap dim\"><time datetime=\"")
670 if templ_7745c5c3_Err != nil {615 if templ_7745c5c3_Err != nil {
671 return templ_7745c5c3_Err616 return templ_7745c5c3_Err
672 }617 }
673- var templ_7745c5c3_Var35 string618+ var templ_7745c5c3_Var31 string
674- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)619+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
675 if templ_7745c5c3_Err != nil {620 if templ_7745c5c3_Err != nil {
676- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 109, Col: 60}621+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 60}
677 }622 }
678- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)623+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
679 if templ_7745c5c3_Err != nil {624 if templ_7745c5c3_Err != nil {
680 return templ_7745c5c3_Err625 return templ_7745c5c3_Err
681 }626 }
682- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" title=\"")627+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" title=\"")
683 if templ_7745c5c3_Err != nil {628 if templ_7745c5c3_Err != nil {
684 return templ_7745c5c3_Err629 return templ_7745c5c3_Err
685 }630 }
686- var templ_7745c5c3_Var36 string631+ var templ_7745c5c3_Var32 string
687- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)632+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(c.WhenAt)
688 if templ_7745c5c3_Err != nil {633 if templ_7745c5c3_Err != nil {
689- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 109, Col: 79}634+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 79}
690 }635 }
691- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)636+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
692 if templ_7745c5c3_Err != nil {637 if templ_7745c5c3_Err != nil {
693 return templ_7745c5c3_Err638 return templ_7745c5c3_Err
694 }639 }
695- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">")640+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
696 if templ_7745c5c3_Err != nil {641 if templ_7745c5c3_Err != nil {
697 return templ_7745c5c3_Err642 return templ_7745c5c3_Err
698 }643 }
699- var templ_7745c5c3_Var37 string644+ var templ_7745c5c3_Var33 string
700- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)645+ templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(c.When)
701 if templ_7745c5c3_Err != nil {646 if templ_7745c5c3_Err != nil {
702- return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 109, Col: 90}647+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/components.templ`, Line: 104, Col: 90}
703 }648 }
704- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))649+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
705 if templ_7745c5c3_Err != nil {650 if templ_7745c5c3_Err != nil {
706 return templ_7745c5c3_Err651 return templ_7745c5c3_Err
707 }652 }
708- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</time></td></tr>")653+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</time></td></tr>")
709 if templ_7745c5c3_Err != nil {654 if templ_7745c5c3_Err != nil {
710 return templ_7745c5c3_Err655 return templ_7745c5c3_Err
711 }656 }
712 }657 }
713- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</tbody></table>")658+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "</tbody></table>")
714 if templ_7745c5c3_Err != nil {659 if templ_7745c5c3_Err != nil {
715 return templ_7745c5c3_Err660 return templ_7745c5c3_Err
716 }661 }
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 generated file: collapsed
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