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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 @@ | |||
| 61 | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 62 | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 63 | 63 | </div> | |
| 64 | - | <div class="days" id="commits-head"> | |
| 64 | + | <div class="days"> | |
| 65 | 65 | <section aria-label="2026-03-01"> | |
| 66 | 66 | <h2 class="rule-h"> | |
| 67 | 67 | <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 @@ | |||
| 65 | 65 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 66 | 66 | </div> | |
| 67 | 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 | 69 | <section aria-label="2026-03-16"> | |
| 70 | 70 | <h2 class="rule-h"> | |
| 71 | 71 | <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 @@ | |||
| 61 | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 62 | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 63 | 63 | </div> | |
| 64 | - | <div class="days" id="commits-head"> | |
| 64 | + | <div id="commits-head"> | |
| 65 | + | </div> | |
| 66 | + | <div class="days"> | |
| 65 | 67 | <section aria-label="2026-03-26"> | |
| 66 | 68 | <h2 class="rule-h"> | |
| 67 | 69 | <span>2026-03-26</span> | |
| @@ -1085,7 +1087,10 @@ | |||
| 1085 | 1087 | <span class="st-ver">ermite devel · </span> | |
| 1086 | 1088 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 1087 | 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&view=commits&ref=main&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 | 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 | 1095 | </footer> | |
| 1091 | 1096 | <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&view=commits&ref=main&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 @@ | |||
| 61 | 61 | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 62 | 62 | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 63 | 63 | </div> | |
| 64 | - | <div class="days" id="commits-head"> | |
| 64 | + | <div id="commits-head"> | |
| 65 | + | </div> | |
| 66 | + | <div class="days"> | |
| 65 | 67 | <section aria-label="2026-02-08"> | |
| 66 | 68 | <h2 class="rule-h"> | |
| 67 | 69 | <span>2026-02-08</span> | |
| @@ -445,7 +447,10 @@ | |||
| 445 | 447 | <span class="st-ver">ermite devel · </span> | |
| 446 | 448 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 447 | 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&view=commits&ref=main&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 | 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 | 455 | </footer> | |
| 451 | 456 | <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&view=commits&ref=main&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 @@ | |||
| 218 | 218 | <span class="st-ver">ermite devel · </span> | |
| 219 | 219 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 220 | 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 | 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 | 226 | </footer> | |
| 224 | 227 | <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 @@ | |||
| 139 | 139 | <summary>refs · legend</summary> | |
| 140 | 140 | <section aria-labelledby="r-refs"> | |
| 141 | 141 | <h2 id="r-refs">refs</h2> | |
| 142 | - | <dl class="kv"> | |
| 142 | + | <dl class="kv" id="refs-kv"> | |
| 143 | 143 | <dt>default</dt> | |
| 144 | 144 | <dd>main</dd> | |
| 145 | 145 | <dt>branches</dt> | |
| @@ -354,7 +354,10 @@ | |||
| 354 | 354 | <span class="st-ver">ermite devel · </span> | |
| 355 | 355 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 356 | 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&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 | 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 | 362 | </footer> | |
| 360 | 363 | <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&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 @@ | |||
| 348 | 348 | <span class="st-ver">ermite devel · </span> | |
| 349 | 349 | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 350 | 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&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 | 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 | 356 | </footer> | |
| 354 | 357 | <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&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) { | |||
| 20 | 20 | srv, err := web.New(web.Config{ | |
| 21 | 21 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |
| 22 | 22 | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | |
| 23 | + | Events: ermiteconnect.NewEventServiceClient(hc, rpc.InProcessURL), | |
| 23 | 24 | PublicURL: cfg.PublicURL, | |
| 24 | 25 | SourceURL: cfg.SourceURL, | |
| 25 | 26 | 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 <b>fresh</b> 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&view=summary"`, | |
| 347 | + | "/demo/refs": `hx-sse:connect="/-/events?topic=repo%3Ademo&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 <b>fresh</b> 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&view=summary"`, | |||
| 347 | + | "/demo/refs": `hx-sse:connect="/-/events?topic=repo%3Ademo&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 { | |||
| 40 | 40 | Open *Link | |
| 41 | 41 | // Refs are the branch heads and tags among the commits of this page. | |
| 42 | 42 | Refs []Link | |
| 43 | + | // Live is true when the page announces new commits of its branch. | |
| 44 | + | Live bool | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | 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 | 129 | l := s.repoLayout(ctx, d, "commits") | |
| 128 | 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 | 136 | l.Crumbs = commitsCrumbs(d, sel) | |
| 130 | 137 | if sel.path == "" { | |
| 131 | 138 | l.Title = "commits · " + d.name() + " · " + s.instance | |
| @@ -40,6 +40,8 @@ type CommitsPage struct { | |||||
| 40 | Open *Link | 40 | 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 []Link | 42 | 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.instance | 138 | 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) { | |||
| 22 | 22 | if p.Path != "" { | |
| 23 | 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 | 29 | for _, g := range p.Days { | |
| 27 | 30 | <section aria-label={ g.Day }> | |
| 28 | 31 | <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) { | |||
| 7 | 7 | <dl class="kv"> | |
| 8 | 8 | for _, f := range facts { | |
| 9 | 9 | <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) | |
| 21 | 11 | } | |
| 22 | 12 | </dl> | |
| 23 | 13 | } | |
| @@ -45,7 +35,12 @@ templ CloneBlock(c Clone) { | |||
| 45 | 35 | templ HomeRowView(r HomeRow) { | |
| 46 | 36 | <tr id={ r.ID } data-cursor?={ r.Cursor }> | |
| 47 | 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 | 44 | <td class="c-desc dim">{ r.Desc }</td> | |
| 50 | 45 | <td class="c-when nowrap"> | |
| 51 | 46 | <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 | - | <dd | 10 | + | @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 | <time | 46 | <time | ||
M internal/adapters/web/components_templ.go +178 -233
| @@ -50,81 +50,16 @@ func Facts(facts []Fact) templ.Component { | |||
| 50 | 50 | if templ_7745c5c3_Err != nil { | |
| 51 | 51 | 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 | 54 | if templ_7745c5c3_Err != nil { | |
| 55 | 55 | return templ_7745c5c3_Err | |
| 56 | 56 | } | |
| 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) | |
| 123 | 58 | if templ_7745c5c3_Err != nil { | |
| 124 | 59 | 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 | 63 | if templ_7745c5c3_Err != nil { | |
| 129 | 64 | return templ_7745c5c3_Err | |
| 130 | 65 | } | |
| @@ -150,61 +85,61 @@ func CloneBlock(c Clone) templ.Component { | |||
| 150 | 85 | }() | |
| 151 | 86 | } | |
| 152 | 87 | 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 | |
| 156 | 91 | } | |
| 157 | 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 | 94 | if templ_7745c5c3_Err != nil { | |
| 160 | 95 | return templ_7745c5c3_Err | |
| 161 | 96 | } | |
| 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) | |
| 164 | 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 | 103 | if templ_7745c5c3_Err != nil { | |
| 169 | 104 | 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 | 107 | if templ_7745c5c3_Err != nil { | |
| 173 | 108 | return templ_7745c5c3_Err | |
| 174 | 109 | } | |
| 175 | 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 | 112 | if templ_7745c5c3_Err != nil { | |
| 178 | 113 | return templ_7745c5c3_Err | |
| 179 | 114 | } | |
| 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) | |
| 182 | 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 | 121 | if templ_7745c5c3_Err != nil { | |
| 187 | 122 | 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 | 125 | if templ_7745c5c3_Err != nil { | |
| 191 | 126 | return templ_7745c5c3_Err | |
| 192 | 127 | } | |
| 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) | |
| 195 | 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 | 134 | if templ_7745c5c3_Err != nil { | |
| 200 | 135 | 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 | 138 | if templ_7745c5c3_Err != nil { | |
| 204 | 139 | 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 | 143 | if templ_7745c5c3_Err != nil { | |
| 209 | 144 | return templ_7745c5c3_Err | |
| 210 | 145 | } | |
| @@ -229,123 +164,133 @@ func HomeRowView(r HomeRow) templ.Component { | |||
| 229 | 164 | }() | |
| 230 | 165 | } | |
| 231 | 166 | 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 | |
| 235 | 170 | } | |
| 236 | 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 | 173 | if templ_7745c5c3_Err != nil { | |
| 239 | 174 | return templ_7745c5c3_Err | |
| 240 | 175 | } | |
| 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) | |
| 243 | 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 | 182 | if templ_7745c5c3_Err != nil { | |
| 248 | 183 | 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 | 186 | if templ_7745c5c3_Err != nil { | |
| 252 | 187 | return templ_7745c5c3_Err | |
| 253 | 188 | } | |
| 254 | 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 | 191 | if templ_7745c5c3_Err != nil { | |
| 257 | 192 | 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 | 196 | if templ_7745c5c3_Err != nil { | |
| 262 | 197 | return templ_7745c5c3_Err | |
| 263 | 198 | } | |
| 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)) | |
| 266 | 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 | 205 | if templ_7745c5c3_Err != nil { | |
| 271 | 206 | 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 | 209 | if templ_7745c5c3_Err != nil { | |
| 275 | 210 | return templ_7745c5c3_Err | |
| 276 | 211 | } | |
| 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) | |
| 279 | 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 | 222 | if templ_7745c5c3_Err != nil { | |
| 284 | 223 | 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 | 232 | if templ_7745c5c3_Err != nil { | |
| 288 | 233 | return templ_7745c5c3_Err | |
| 289 | 234 | } | |
| 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) | |
| 292 | 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 | 241 | if templ_7745c5c3_Err != nil { | |
| 297 | 242 | 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 | 245 | if templ_7745c5c3_Err != nil { | |
| 301 | 246 | return templ_7745c5c3_Err | |
| 302 | 247 | } | |
| 303 | 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 | 250 | if templ_7745c5c3_Err != nil { | |
| 306 | 251 | return templ_7745c5c3_Err | |
| 307 | 252 | } | |
| 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) | |
| 310 | 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 | 259 | if templ_7745c5c3_Err != nil { | |
| 315 | 260 | 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 | 263 | if templ_7745c5c3_Err != nil { | |
| 319 | 264 | 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 | 268 | if templ_7745c5c3_Err != nil { | |
| 324 | 269 | return templ_7745c5c3_Err | |
| 325 | 270 | } | |
| 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) | |
| 328 | 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 | 277 | if templ_7745c5c3_Err != nil { | |
| 333 | 278 | 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 | 281 | if templ_7745c5c3_Err != nil { | |
| 337 | 282 | return templ_7745c5c3_Err | |
| 338 | 283 | } | |
| 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) | |
| 341 | 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 | 290 | if templ_7745c5c3_Err != nil { | |
| 346 | 291 | 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 | 294 | if templ_7745c5c3_Err != nil { | |
| 350 | 295 | return templ_7745c5c3_Err | |
| 351 | 296 | } | |
| @@ -370,66 +315,66 @@ func Outline(headings []Heading) templ.Component { | |||
| 370 | 315 | }() | |
| 371 | 316 | } | |
| 372 | 317 | 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 | |
| 376 | 321 | } | |
| 377 | 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 | 324 | if templ_7745c5c3_Err != nil { | |
| 380 | 325 | return templ_7745c5c3_Err | |
| 381 | 326 | } | |
| 382 | 327 | 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...) | |
| 385 | 330 | if templ_7745c5c3_Err != nil { | |
| 386 | 331 | 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 | 334 | if templ_7745c5c3_Err != nil { | |
| 390 | 335 | return templ_7745c5c3_Err | |
| 391 | 336 | } | |
| 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()) | |
| 394 | 339 | if templ_7745c5c3_Err != nil { | |
| 395 | 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 | 343 | if templ_7745c5c3_Err != nil { | |
| 399 | 344 | 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 | 347 | if templ_7745c5c3_Err != nil { | |
| 403 | 348 | return templ_7745c5c3_Err | |
| 404 | 349 | } | |
| 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)) | |
| 407 | 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 | 356 | if templ_7745c5c3_Err != nil { | |
| 412 | 357 | 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 | 360 | if templ_7745c5c3_Err != nil { | |
| 416 | 361 | return templ_7745c5c3_Err | |
| 417 | 362 | } | |
| 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) | |
| 420 | 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 | 369 | if templ_7745c5c3_Err != nil { | |
| 425 | 370 | 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 | 373 | if templ_7745c5c3_Err != nil { | |
| 429 | 374 | 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 | 378 | if templ_7745c5c3_Err != nil { | |
| 434 | 379 | return templ_7745c5c3_Err | |
| 435 | 380 | } | |
| @@ -455,43 +400,43 @@ func ReadmeView(r Readme) templ.Component { | |||
| 455 | 400 | }() | |
| 456 | 401 | } | |
| 457 | 402 | 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 | |
| 461 | 406 | } | |
| 462 | 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 | 409 | if templ_7745c5c3_Err != nil { | |
| 465 | 410 | return templ_7745c5c3_Err | |
| 466 | 411 | } | |
| 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) | |
| 469 | 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 | 418 | if templ_7745c5c3_Err != nil { | |
| 474 | 419 | 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 | 422 | if templ_7745c5c3_Err != nil { | |
| 478 | 423 | return templ_7745c5c3_Err | |
| 479 | 424 | } | |
| 480 | 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 | 427 | if templ_7745c5c3_Err != nil { | |
| 483 | 428 | return templ_7745c5c3_Err | |
| 484 | 429 | } | |
| 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) | |
| 487 | 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 | 436 | if templ_7745c5c3_Err != nil { | |
| 492 | 437 | 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 | 440 | if templ_7745c5c3_Err != nil { | |
| 496 | 441 | return templ_7745c5c3_Err | |
| 497 | 442 | } | |
| @@ -501,7 +446,7 @@ func ReadmeView(r Readme) templ.Component { | |||
| 501 | 446 | 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 | 450 | if templ_7745c5c3_Err != nil { | |
| 506 | 451 | return templ_7745c5c3_Err | |
| 507 | 452 | } | |
| @@ -527,190 +472,190 @@ func CommitRows(rows []CommitRow) templ.Component { | |||
| 527 | 472 | }() | |
| 528 | 473 | } | |
| 529 | 474 | 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 | |
| 533 | 478 | } | |
| 534 | 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 | 481 | if templ_7745c5c3_Err != nil { | |
| 537 | 482 | return templ_7745c5c3_Err | |
| 538 | 483 | } | |
| 539 | 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 | 486 | if templ_7745c5c3_Err != nil { | |
| 542 | 487 | return templ_7745c5c3_Err | |
| 543 | 488 | } | |
| 544 | 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 | 491 | if templ_7745c5c3_Err != nil { | |
| 547 | 492 | return templ_7745c5c3_Err | |
| 548 | 493 | } | |
| 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) | |
| 551 | 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 | 500 | if templ_7745c5c3_Err != nil { | |
| 556 | 501 | 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 | 504 | if templ_7745c5c3_Err != nil { | |
| 560 | 505 | return templ_7745c5c3_Err | |
| 561 | 506 | } | |
| 562 | 507 | } | |
| 563 | 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 | 510 | if templ_7745c5c3_Err != nil { | |
| 566 | 511 | 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 | 515 | if templ_7745c5c3_Err != nil { | |
| 571 | 516 | return templ_7745c5c3_Err | |
| 572 | 517 | } | |
| 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) | |
| 575 | 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 | 524 | if templ_7745c5c3_Err != nil { | |
| 580 | 525 | 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 | 528 | if templ_7745c5c3_Err != nil { | |
| 584 | 529 | return templ_7745c5c3_Err | |
| 585 | 530 | } | |
| 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)) | |
| 588 | 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 | 537 | if templ_7745c5c3_Err != nil { | |
| 593 | 538 | 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 | 541 | if templ_7745c5c3_Err != nil { | |
| 597 | 542 | return templ_7745c5c3_Err | |
| 598 | 543 | } | |
| 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) | |
| 601 | 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 | 550 | if templ_7745c5c3_Err != nil { | |
| 606 | 551 | 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 | 554 | if templ_7745c5c3_Err != nil { | |
| 610 | 555 | return templ_7745c5c3_Err | |
| 611 | 556 | } | |
| 612 | 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 | 559 | if templ_7745c5c3_Err != nil { | |
| 615 | 560 | return templ_7745c5c3_Err | |
| 616 | 561 | } | |
| 617 | 562 | } | |
| 618 | 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 | 565 | if templ_7745c5c3_Err != nil { | |
| 621 | 566 | return templ_7745c5c3_Err | |
| 622 | 567 | } | |
| 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) | |
| 625 | 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 | 574 | if templ_7745c5c3_Err != nil { | |
| 630 | 575 | 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 | 578 | if templ_7745c5c3_Err != nil { | |
| 634 | 579 | return templ_7745c5c3_Err | |
| 635 | 580 | } | |
| 636 | 581 | } | |
| 637 | 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 | 584 | if templ_7745c5c3_Err != nil { | |
| 640 | 585 | return templ_7745c5c3_Err | |
| 641 | 586 | } | |
| 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) | |
| 644 | 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 | 593 | if templ_7745c5c3_Err != nil { | |
| 649 | 594 | 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 | 597 | if templ_7745c5c3_Err != nil { | |
| 653 | 598 | 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 | 602 | if templ_7745c5c3_Err != nil { | |
| 658 | 603 | return templ_7745c5c3_Err | |
| 659 | 604 | } | |
| 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) | |
| 662 | 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 | 611 | if templ_7745c5c3_Err != nil { | |
| 667 | 612 | 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 | 615 | if templ_7745c5c3_Err != nil { | |
| 671 | 616 | return templ_7745c5c3_Err | |
| 672 | 617 | } | |
| 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) | |
| 675 | 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 | 624 | if templ_7745c5c3_Err != nil { | |
| 680 | 625 | 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 | 628 | if templ_7745c5c3_Err != nil { | |
| 684 | 629 | return templ_7745c5c3_Err | |
| 685 | 630 | } | |
| 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) | |
| 688 | 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 | 637 | if templ_7745c5c3_Err != nil { | |
| 693 | 638 | 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 | 641 | if templ_7745c5c3_Err != nil { | |
| 697 | 642 | return templ_7745c5c3_Err | |
| 698 | 643 | } | |
| 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) | |
| 701 | 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 | 650 | if templ_7745c5c3_Err != nil { | |
| 706 | 651 | 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 | 654 | if templ_7745c5c3_Err != nil { | |
| 710 | 655 | 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 | 659 | if templ_7745c5c3_Err != nil { | |
| 715 | 660 | return templ_7745c5c3_Err | |
| 716 | 661 | } | |
| @@ -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_Err | 51 | 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_Err | 55 | 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_Err | 59 | 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_Err | 64 | 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.NopComponent | 90 | + | 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_Err | 95 | return templ_7745c5c3_Err | ||
| 161 | } | 96 | } | ||
| 162 | - | var templ_7745c5c3_Var8 string | 97 | + | 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_Err | 104 | 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_Err | 108 | 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_Err | 113 | return templ_7745c5c3_Err | ||
| 179 | } | 114 | } | ||
| 180 | - | var templ_7745c5c3_Var9 string | 115 | + | 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_Err | 122 | 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_Err | 126 | return templ_7745c5c3_Err | ||
| 192 | } | 127 | } | ||
| 193 | - | var templ_7745c5c3_Var10 string | 128 | + | 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_Err | 135 | 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_Err | 139 | 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_Err | 144 | 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.NopComponent | 169 | + | 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_Err | 174 | return templ_7745c5c3_Err | ||
| 240 | } | 175 | } | ||
| 241 | - | var templ_7745c5c3_Var12 string | 176 | + | 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_Err | 183 | 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_Err | 187 | 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_Err | 192 | 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_Err | 197 | return templ_7745c5c3_Err | ||
| 263 | } | 198 | } | ||
| 264 | - | var templ_7745c5c3_Var13 templ.SafeURL | 199 | + | 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_Err | 206 | 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_Err | 210 | return templ_7745c5c3_Err | ||
| 276 | } | 211 | } | ||
| 277 | - | var templ_7745c5c3_Var14 string | 212 | + | 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_Err | 223 | 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_Err | 233 | return templ_7745c5c3_Err | ||
| 289 | } | 234 | } | ||
| 290 | - | var templ_7745c5c3_Var15 string | 235 | + | 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_Err | 242 | 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_Err | 246 | 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_Err | 251 | return templ_7745c5c3_Err | ||
| 307 | } | 252 | } | ||
| 308 | - | var templ_7745c5c3_Var16 string | 253 | + | 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_Err | 260 | 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_Err | 264 | 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_Err | 269 | return templ_7745c5c3_Err | ||
| 325 | } | 270 | } | ||
| 326 | - | var templ_7745c5c3_Var17 string | 271 | + | 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_Err | 278 | 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_Err | 282 | return templ_7745c5c3_Err | ||
| 338 | } | 283 | } | ||
| 339 | - | var templ_7745c5c3_Var18 string | 284 | + | 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_Err | 291 | 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_Err | 295 | 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.NopComponent | 320 | + | 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_Err | 325 | 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_Err | 331 | 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_Err | 335 | return templ_7745c5c3_Err | ||
| 391 | } | 336 | } | ||
| 392 | - | var templ_7745c5c3_Var21 string | 337 | + | 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_Err | 344 | 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_Err | 348 | return templ_7745c5c3_Err | ||
| 404 | } | 349 | } | ||
| 405 | - | var templ_7745c5c3_Var22 templ.SafeURL | 350 | + | 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_Err | 357 | 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_Err | 361 | return templ_7745c5c3_Err | ||
| 417 | } | 362 | } | ||
| 418 | - | var templ_7745c5c3_Var23 string | 363 | + | 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_Err | 370 | 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_Err | 374 | 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_Err | 379 | 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.NopComponent | 405 | + | 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_Err | 410 | return templ_7745c5c3_Err | ||
| 466 | } | 411 | } | ||
| 467 | - | var templ_7745c5c3_Var25 string | 412 | + | 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_Err | 419 | 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_Err | 423 | 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_Err | 428 | return templ_7745c5c3_Err | ||
| 484 | } | 429 | } | ||
| 485 | - | var templ_7745c5c3_Var26 string | 430 | + | 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_Err | 437 | 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_Err | 441 | return templ_7745c5c3_Err | ||
| 497 | } | 442 | } | ||
| @@ -501,7 +446,7 @@ func ReadmeView(r Readme) templ.Component { | |||||
| 501 | return templ_7745c5c3_Err | 446 | 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_Err | 451 | 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.NopComponent | 477 | + | 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_Err | 482 | 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_Err | 487 | 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_Err | 492 | return templ_7745c5c3_Err | ||
| 548 | } | 493 | } | ||
| 549 | - | var templ_7745c5c3_Var28 string | 494 | + | 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_Err | 501 | 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_Err | 505 | 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_Err | 511 | 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_Err | 516 | return templ_7745c5c3_Err | ||
| 572 | } | 517 | } | ||
| 573 | - | var templ_7745c5c3_Var29 string | 518 | + | 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_Err | 525 | 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_Err | 529 | return templ_7745c5c3_Err | ||
| 585 | } | 530 | } | ||
| 586 | - | var templ_7745c5c3_Var30 templ.SafeURL | 531 | + | 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_Err | 538 | 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_Err | 542 | return templ_7745c5c3_Err | ||
| 598 | } | 543 | } | ||
| 599 | - | var templ_7745c5c3_Var31 string | 544 | + | 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_Err | 551 | 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_Err | 555 | 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_Err | 560 | 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_Err | 566 | return templ_7745c5c3_Err | ||
| 622 | } | 567 | } | ||
| 623 | - | var templ_7745c5c3_Var32 string | 568 | + | 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_Err | 575 | 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_Err | 579 | 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_Err | 585 | return templ_7745c5c3_Err | ||
| 641 | } | 586 | } | ||
| 642 | - | var templ_7745c5c3_Var33 string | 587 | + | 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_Err | 594 | 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_Err | 598 | 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_Err | 603 | return templ_7745c5c3_Err | ||
| 659 | } | 604 | } | ||
| 660 | - | var templ_7745c5c3_Var34 string | 605 | + | 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_Err | 612 | 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_Err | 616 | return templ_7745c5c3_Err | ||
| 672 | } | 617 | } | ||
| 673 | - | var templ_7745c5c3_Var35 string | 618 | + | 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_Err | 625 | 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_Err | 629 | return templ_7745c5c3_Err | ||
| 685 | } | 630 | } | ||
| 686 | - | var templ_7745c5c3_Var36 string | 631 | + | 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_Err | 638 | 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_Err | 642 | return templ_7745c5c3_Err | ||
| 698 | } | 643 | } | ||
| 699 | - | var templ_7745c5c3_Var37 string | 644 | + | 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_Err | 651 | 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_Err | 655 | 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_Err | 660 | 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 | |||
| 108 | 108 | ||
| 109 | 109 | l := s.base() | |
| 110 | 110 | l.Title = s.instance | |
| 111 | + | l.Live, l.LiveURL = true, liveURL("home", "") | |
| 111 | 112 | l.Sections = []Section{{Key: "h", Post: "ome", Href: "/", Current: true}} | |
| 112 | 113 | dir := "▲" | |
| 113 | 114 | 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.instance | 110 | 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) { | |||
| 92 | 92 | <thead> | |
| 93 | 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 | 94 | </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) | |
| 103 | 96 | </table> | |
| 104 | 97 | if p.Note != "" { | |
| 105 | 98 | <p class="note">{ p.Note }</p> | |
| @@ -136,18 +129,7 @@ templ refRow(kind string, r RefRow) { | |||
| 136 | 129 | templ refsRail(p RefsPage) { | |
| 137 | 130 | <section aria-labelledby="r-refs"> | |
| 138 | 131 | <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) | |
| 151 | 133 | </section> | |
| 152 | 134 | <section aria-labelledby="r-legend"> | |
| 153 | 135 | <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 | |||
| 19 | 19 | if err != nil { | |
| 20 | 20 | 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 | 40 | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |
| 23 | 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 | 63 | if d.cut { | |
| 46 | 64 | p.Note = "this repository has more refs than are listed here." | |
| 47 | 65 | } | |
| 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 | |
| 59 | 67 | } | |
| 60 | 68 | ||
| 61 | 69 | 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, err | 20 | 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) { | |||
| 119 | 119 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | |
| 120 | 120 | <span class="st-hints">{ l.Status.Hints }</span> | |
| 121 | 121 | @Footer(l) | |
| 122 | - | @Live(l.Live) | |
| 122 | + | @Live(l) | |
| 123 | 123 | if l.Status.Cursor { | |
| 124 | 124 | <span | |
| 125 | 125 | class="st-pos num" | |
| @@ -135,24 +135,3 @@ templ Statusbar(l Layout) { | |||
| 135 | 135 | templ Footer(l Layout) { | |
| 136 | 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 | - | } | |
| @@ -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 | <span | 124 | <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 @@ | |||
| 30 | 30 | // PageUp/PageDown and space scroll the content without a click first. | |
| 31 | 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 | + | })(); | |
| @@ -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, | |||
| 78 | 78 | ||
| 79 | 79 | l := s.repoLayout(ctx, d, "summary") | |
| 80 | 80 | l.Title = d.name() + " · " + s.instance | |
| 81 | + | l.Live, l.LiveURL = true, liveURL("repo:"+d.name(), "view=summary") | |
| 81 | 82 | l.Status = Status{ | |
| 82 | 83 | Mode: "summary", Context: plural(len(commits.Msg.GetCommits()), "recent commit", "recent commits"), | |
| 83 | 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 | 94 | // facts are the key/value rows of the rail. | |
| 94 | 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 | 96 | fs := []Fact{ | |
| 101 | - | last, | |
| 97 | + | lastPushFact(d.repo, now), | |
| 102 | 98 | {Key: "branch", Val: d.head.GetShortName() + " · " + plural(len(d.branches), "branch", "branches")}, | |
| 103 | 99 | } | |
| 104 | 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 | 112 | 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 | 126 | // latestTag is the tag whose commit is the newest. | |
| 120 | 127 | func latestTag(tags []*pb.Ref, commits map[string]refCommit) (string, refCommit) { | |
| 121 | 128 | 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.instance | 80 | 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 fs | 112 | 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 string | 128 | var name string | ||
M internal/adapters/web/view.go +4 -0
| @@ -65,6 +65,8 @@ type Layout struct { | |||
| 65 | 65 | Read bool // prose page: content capped at the reading measure | |
| 66 | 66 | // Live connects the status bar to the event stream (/-/events). | |
| 67 | 67 | Live bool | |
| 68 | + | // LiveURL is the stream the page follows, with its query. | |
| 69 | + | LiveURL string | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | 72 | // Link is a plain label + href pair. | |
| @@ -81,6 +83,8 @@ type HomeRow struct { | |||
| 81 | 83 | Branch string | |
| 82 | 84 | Tag string | |
| 83 | 85 | Cursor bool | |
| 86 | + | // Fresh marks a row that a push just updated (live updates only). | |
| 87 | + | Fresh bool | |
| 84 | 88 | } | |
| 85 | 89 | ||
| 86 | 90 | // 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 measure | 65 | 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 bool | 67 | 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 string | 83 | Branch string | ||
| 82 | Tag string | 84 | Tag string | ||
| 83 | Cursor bool | 85 | 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 { | |||
| 47 | 47 | // Repos and Git are the API clients the pages are built from. | |
| 48 | 48 | Repos ermiteconnect.RepoServiceClient | |
| 49 | 49 | 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 | 56 | // PublicURL is the base URL of the instance, without a trailing slash: | |
| 51 | 57 | // the clone URLs are built on it, and its host is the instance name. | |
| 52 | 58 | PublicURL string | |
| @@ -94,6 +100,7 @@ func New(cfg Config) (*Server, error) { | |||
| 94 | 100 | func (s *Server) routes() *http.ServeMux { | |
| 95 | 101 | mux := http.NewServeMux() | |
| 96 | 102 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| 103 | + | mux.HandleFunc("GET /-/events", s.events) | |
| 97 | 104 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 98 | 105 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 99 | 106 | 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.RepoServiceClient | 48 | Repos ermiteconnect.RepoServiceClient | ||
| 49 | Git ermiteconnect.GitServiceClient | 49 | 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 string | 58 | 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)) | ||