feat(web): add commit history pages
/<repo>/commits/<ref>[/<path>]: the commits of a ref, a tag or a commit id grouped by day, with an older link that carries the API page token, the path filter (404 for a path that never existed), refs with a slash resolved by the longest prefix, and the refs of the page in the rail. Invisible characters in names and subjects show as <U+XXXX> markers.
- commit
- dad3569cfc8c62b2aa45376df8791db673ac93df
- author
- brice · · 2026-10-10 13:03 UTC
- parent
- 25ce556
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +3353 -0
A cmd/ermite/testdata/web/commits-page2.html +340 -0
| @@ -0,0 +1,340 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>commits · long · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/long">long</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/long/commits/main">commits</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/long/commits/main" aria-current="page">main</a> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <div class="filter"> | |
| 59 | + | <label for="commit-filter">filter></label> | |
| 60 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 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')"> | |
| 63 | + | </div> | |
| 64 | + | <div class="days" id="commits-head"> | |
| 65 | + | <section aria-label="2026-03-01"> | |
| 66 | + | <h2 class="rule-h"> | |
| 67 | + | <span>2026-03-01</span> | |
| 68 | + | </h2> | |
| 69 | + | <table class="list commits" aria-label="Commits"> | |
| 70 | + | <tbody data-rows> | |
| 71 | + | <tr id="c-SHA7" data-cursor> | |
| 72 | + | <td class="cur" aria-hidden="true"> | |
| 73 | + | </td> | |
| 74 | + | <td class="c-sha mute">SHA7</td> | |
| 75 | + | <td class="c-subject"> | |
| 76 | + | <a href="/long/commit/SHA40">commit number 1</a> | |
| 77 | + | </td> | |
| 78 | + | <td class="c-who dim">T</td> | |
| 79 | + | <td class="c-when nowrap dim"> | |
| 80 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 81 | + | </td> | |
| 82 | + | </tr> | |
| 83 | + | <tr id="c-SHA7"> | |
| 84 | + | <td class="cur" aria-hidden="true"> | |
| 85 | + | </td> | |
| 86 | + | <td class="c-sha mute">SHA7</td> | |
| 87 | + | <td class="c-subject"> | |
| 88 | + | <a href="/long/commit/SHA40">commit number 0</a> | |
| 89 | + | </td> | |
| 90 | + | <td class="c-who dim">T</td> | |
| 91 | + | <td class="c-when nowrap dim"> | |
| 92 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 93 | + | </td> | |
| 94 | + | </tr> | |
| 95 | + | </tbody> | |
| 96 | + | </table> | |
| 97 | + | </section> | |
| 98 | + | </div> | |
| 99 | + | <nav class="pager" aria-label="Pages"> | |
| 100 | + | <a href="/long/commits/main">← newest</a> | |
| 101 | + | <span class="mute">older →</span> | |
| 102 | + | </nav> | |
| 103 | + | </main> | |
| 104 | + | <aside class="rail" aria-label="Context"> | |
| 105 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 106 | + | <summary>history · refs on this page</summary> | |
| 107 | + | <section aria-labelledby="r-hist"> | |
| 108 | + | <h2 id="r-hist">history</h2> | |
| 109 | + | <dl class="kv"> | |
| 110 | + | <dt>ref</dt> | |
| 111 | + | <dd>main</dd> | |
| 112 | + | <dt>on page</dt> | |
| 113 | + | <dd>2</dd> | |
| 114 | + | </dl> | |
| 115 | + | </section> | |
| 116 | + | <section aria-labelledby="r-refs"> | |
| 117 | + | <h2 id="r-refs">refs on this page</h2> | |
| 118 | + | <p class="dim">no tag or branch head here</p> | |
| 119 | + | </section> | |
| 120 | + | </details> | |
| 121 | + | </aside> | |
| 122 | + | </div> | |
| 123 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 124 | + | <div class="panel-head"> | |
| 125 | + | <span>keys</span> | |
| 126 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 127 | + | </div> | |
| 128 | + | <dl class="keys"> | |
| 129 | + | <div> | |
| 130 | + | <dt> | |
| 131 | + | <kbd>j</kbd> | |
| 132 | + | <kbd>k</kbd> | |
| 133 | + | </dt> | |
| 134 | + | <dd>move cursor down / up</dd> | |
| 135 | + | </div> | |
| 136 | + | <div> | |
| 137 | + | <dt> | |
| 138 | + | <kbd>enter</kbd> | |
| 139 | + | </dt> | |
| 140 | + | <dd>open row under cursor</dd> | |
| 141 | + | </div> | |
| 142 | + | <div> | |
| 143 | + | <dt> | |
| 144 | + | <kbd>g</kbd> | |
| 145 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 146 | + | </dt> | |
| 147 | + | <dd>first / last row</dd> | |
| 148 | + | </div> | |
| 149 | + | <div> | |
| 150 | + | <dt> | |
| 151 | + | <kbd>/</kbd> | |
| 152 | + | </dt> | |
| 153 | + | <dd>focus the filter</dd> | |
| 154 | + | </div> | |
| 155 | + | <div> | |
| 156 | + | <dt> | |
| 157 | + | <kbd>g h</kbd> | |
| 158 | + | <kbd>g t</kbd> | |
| 159 | + | </dt> | |
| 160 | + | <dd>go home / tree</dd> | |
| 161 | + | </div> | |
| 162 | + | <div> | |
| 163 | + | <dt> | |
| 164 | + | <kbd>g c</kbd> | |
| 165 | + | <kbd>g r</kbd> | |
| 166 | + | </dt> | |
| 167 | + | <dd>go commits / refs</dd> | |
| 168 | + | </div> | |
| 169 | + | <div> | |
| 170 | + | <dt> | |
| 171 | + | <kbd>b</kbd> | |
| 172 | + | </dt> | |
| 173 | + | <dd>switch ref (branches, tags)</dd> | |
| 174 | + | </div> | |
| 175 | + | <div> | |
| 176 | + | <dt> | |
| 177 | + | <kbd>:</kbd> | |
| 178 | + | <kbd>f</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>go to: section, ref, file, repo</dd> | |
| 181 | + | </div> | |
| 182 | + | <div> | |
| 183 | + | <dt> | |
| 184 | + | <kbd>y</kbd> | |
| 185 | + | </dt> | |
| 186 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 187 | + | </div> | |
| 188 | + | <div> | |
| 189 | + | <dt> | |
| 190 | + | <kbd>s</kbd> | |
| 191 | + | <kbd>t</kbd> | |
| 192 | + | <kbd>c</kbd> | |
| 193 | + | <kbd>r</kbd> | |
| 194 | + | </dt> | |
| 195 | + | <dd>repo sections (when shown)</dd> | |
| 196 | + | </div> | |
| 197 | + | <div> | |
| 198 | + | <dt> | |
| 199 | + | <kbd>d</kbd> | |
| 200 | + | <kbd>u</kbd> | |
| 201 | + | <kbd>x</kbd> | |
| 202 | + | </dt> | |
| 203 | + | <dd>commit: split / unified / collapse all</dd> | |
| 204 | + | </div> | |
| 205 | + | <div> | |
| 206 | + | <dt> | |
| 207 | + | <kbd>esc</kbd> | |
| 208 | + | </dt> | |
| 209 | + | <dd>close panel</dd> | |
| 210 | + | </div> | |
| 211 | + | </dl> | |
| 212 | + | </dialog> | |
| 213 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 214 | + | <div class="panel-head"> | |
| 215 | + | <label for="refs-filter">ref></label> | |
| 216 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 217 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 218 | + | </div> | |
| 219 | + | <div class="panel-cols" data-picklist> | |
| 220 | + | <section aria-label="Branches"> | |
| 221 | + | <h2>branches <span class="mute">1</span> | |
| 222 | + | </h2> | |
| 223 | + | <ul> | |
| 224 | + | <li> | |
| 225 | + | <a href="/long/tree/main" aria-current="true"> | |
| 226 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 227 | + | </li> | |
| 228 | + | </ul> | |
| 229 | + | </section> | |
| 230 | + | <section aria-label="Tags"> | |
| 231 | + | <h2>tags <span class="mute">0</span> | |
| 232 | + | </h2> | |
| 233 | + | <ul> | |
| 234 | + | </ul> | |
| 235 | + | </section> | |
| 236 | + | </div> | |
| 237 | + | </dialog> | |
| 238 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 239 | + | <div class="panel-head"> | |
| 240 | + | <label for="cmd-q" class="hi">:</label> | |
| 241 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 242 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 243 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 244 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 245 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 246 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 247 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 248 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 249 | + | </div> | |
| 250 | + | <ul id="cmd-list" class="cmd-list"> | |
| 251 | + | <li data-k="go home" data-cursor> | |
| 252 | + | <a href="/" tabindex="-1"> | |
| 253 | + | <span class="k">go</span>home</a> | |
| 254 | + | </li> | |
| 255 | + | <li data-k="section summary"> | |
| 256 | + | <a href="/long" tabindex="-1"> | |
| 257 | + | <span class="k">section</span>summary</a> | |
| 258 | + | </li> | |
| 259 | + | <li data-k="section tree"> | |
| 260 | + | <a href="/long/tree/main" tabindex="-1"> | |
| 261 | + | <span class="k">section</span>tree</a> | |
| 262 | + | </li> | |
| 263 | + | <li data-k="section commits"> | |
| 264 | + | <a href="/long/commits/main" tabindex="-1"> | |
| 265 | + | <span class="k">section</span>commits</a> | |
| 266 | + | </li> | |
| 267 | + | <li data-k="section refs"> | |
| 268 | + | <a href="/long/refs" tabindex="-1"> | |
| 269 | + | <span class="k">section</span>refs</a> | |
| 270 | + | </li> | |
| 271 | + | <li data-k="branch main"> | |
| 272 | + | <a href="/long/tree/main" tabindex="-1"> | |
| 273 | + | <span class="k">branch</span>main</a> | |
| 274 | + | </li> | |
| 275 | + | <li data-k="repo diffs"> | |
| 276 | + | <a href="/diffs" tabindex="-1"> | |
| 277 | + | <span class="k">repo</span>diffs</a> | |
| 278 | + | </li> | |
| 279 | + | </ul> | |
| 280 | + | </dialog> | |
| 281 | + | <footer class="bar status" aria-label="Status"> | |
| 282 | + | <span class="mode">commits</span> | |
| 283 | + | <span class="st-ctx">main · 2 commits</span> | |
| 284 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 285 | + | <span class="hi">:</span> go to…</button> | |
| 286 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 287 | + | </span> | |
| 288 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |
| 289 | + | <span class="st-src"> | |
| 290 | + | <span class="st-ver">ermite devel · </span> | |
| 291 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 292 | + | </span> | |
| 293 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 294 | + | <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> | |
| 295 | + | </footer> | |
| 296 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 297 | + | if (q('dialog[open]').count) return; | |
| 298 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 299 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 300 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 301 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 302 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 303 | + | })(1); | |
| 304 | + | k-k from:body -> ((d) => { | |
| 305 | + | if (q('dialog[open]').count) return; | |
| 306 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 307 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 308 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 309 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 310 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 311 | + | })(-1); | |
| 312 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 313 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 314 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 315 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 316 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 317 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 318 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 319 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 320 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 321 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 322 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 323 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 324 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 325 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 326 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 327 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 328 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 329 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 330 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 331 | + | k-permalink from:body -> { | |
| 332 | + | let b = document.body.dataset; | |
| 333 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 334 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 335 | + | navigator.clipboard?.writeText(location.href); | |
| 336 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 337 | + | }"> | |
| 338 | + | </div> | |
| 339 | + | </body> | |
| 340 | + | </html> | |
| @@ -0,0 +1,340 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>commits · long · example.test</title> | |||
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 10 | + | </script> | |||
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 12 | + | </script> | |||
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 14 | + | </script> | |||
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 16 | + | </script> | |||
| 17 | + | </head> | |||
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |||
| 19 | + | <a class="skip" href="#main">skip to content</a> | |||
| 20 | + | <header class="bar top"> | |||
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |||
| 22 | + | <span class="fade" aria-hidden="true"> | |||
| 23 | + | </span> | |||
| 24 | + | <nav class="crumbs" aria-label="Path"> | |||
| 25 | + | <ol> | |||
| 26 | + | <li> | |||
| 27 | + | <a href="/long">long</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/long/commits/main">commits</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/long/commits/main" aria-current="page">main</a> | |||
| 34 | + | </li> | |||
| 35 | + | </ol> | |||
| 36 | + | </nav> | |||
| 37 | + | <nav class="secs" aria-label="Sections"> | |||
| 38 | + | <ul> | |||
| 39 | + | <li> | |||
| 40 | + | <a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 41 | + | </li> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |||
| 50 | + | </li> | |||
| 51 | + | </ul> | |||
| 52 | + | </nav> | |||
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |||
| 54 | + | </button> | |||
| 55 | + | </header> | |||
| 56 | + | <div class="work has-rail"> | |||
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 58 | + | <div class="filter"> | |||
| 59 | + | <label for="commit-filter">filter></label> | |||
| 60 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |||
| 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')"> | |||
| 63 | + | </div> | |||
| 64 | + | <div class="days" id="commits-head"> | |||
| 65 | + | <section aria-label="2026-03-01"> | |||
| 66 | + | <h2 class="rule-h"> | |||
| 67 | + | <span>2026-03-01</span> | |||
| 68 | + | </h2> | |||
| 69 | + | <table class="list commits" aria-label="Commits"> | |||
| 70 | + | <tbody data-rows> | |||
| 71 | + | <tr id="c-SHA7" data-cursor> | |||
| 72 | + | <td class="cur" aria-hidden="true"> | |||
| 73 | + | </td> | |||
| 74 | + | <td class="c-sha mute">SHA7</td> | |||
| 75 | + | <td class="c-subject"> | |||
| 76 | + | <a href="/long/commit/SHA40">commit number 1</a> | |||
| 77 | + | </td> | |||
| 78 | + | <td class="c-who dim">T</td> | |||
| 79 | + | <td class="c-when nowrap dim"> | |||
| 80 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 81 | + | </td> | |||
| 82 | + | </tr> | |||
| 83 | + | <tr id="c-SHA7"> | |||
| 84 | + | <td class="cur" aria-hidden="true"> | |||
| 85 | + | </td> | |||
| 86 | + | <td class="c-sha mute">SHA7</td> | |||
| 87 | + | <td class="c-subject"> | |||
| 88 | + | <a href="/long/commit/SHA40">commit number 0</a> | |||
| 89 | + | </td> | |||
| 90 | + | <td class="c-who dim">T</td> | |||
| 91 | + | <td class="c-when nowrap dim"> | |||
| 92 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 93 | + | </td> | |||
| 94 | + | </tr> | |||
| 95 | + | </tbody> | |||
| 96 | + | </table> | |||
| 97 | + | </section> | |||
| 98 | + | </div> | |||
| 99 | + | <nav class="pager" aria-label="Pages"> | |||
| 100 | + | <a href="/long/commits/main">← newest</a> | |||
| 101 | + | <span class="mute">older →</span> | |||
| 102 | + | </nav> | |||
| 103 | + | </main> | |||
| 104 | + | <aside class="rail" aria-label="Context"> | |||
| 105 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 106 | + | <summary>history · refs on this page</summary> | |||
| 107 | + | <section aria-labelledby="r-hist"> | |||
| 108 | + | <h2 id="r-hist">history</h2> | |||
| 109 | + | <dl class="kv"> | |||
| 110 | + | <dt>ref</dt> | |||
| 111 | + | <dd>main</dd> | |||
| 112 | + | <dt>on page</dt> | |||
| 113 | + | <dd>2</dd> | |||
| 114 | + | </dl> | |||
| 115 | + | </section> | |||
| 116 | + | <section aria-labelledby="r-refs"> | |||
| 117 | + | <h2 id="r-refs">refs on this page</h2> | |||
| 118 | + | <p class="dim">no tag or branch head here</p> | |||
| 119 | + | </section> | |||
| 120 | + | </details> | |||
| 121 | + | </aside> | |||
| 122 | + | </div> | |||
| 123 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 124 | + | <div class="panel-head"> | |||
| 125 | + | <span>keys</span> | |||
| 126 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 127 | + | </div> | |||
| 128 | + | <dl class="keys"> | |||
| 129 | + | <div> | |||
| 130 | + | <dt> | |||
| 131 | + | <kbd>j</kbd> | |||
| 132 | + | <kbd>k</kbd> | |||
| 133 | + | </dt> | |||
| 134 | + | <dd>move cursor down / up</dd> | |||
| 135 | + | </div> | |||
| 136 | + | <div> | |||
| 137 | + | <dt> | |||
| 138 | + | <kbd>enter</kbd> | |||
| 139 | + | </dt> | |||
| 140 | + | <dd>open row under cursor</dd> | |||
| 141 | + | </div> | |||
| 142 | + | <div> | |||
| 143 | + | <dt> | |||
| 144 | + | <kbd>g</kbd> | |||
| 145 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 146 | + | </dt> | |||
| 147 | + | <dd>first / last row</dd> | |||
| 148 | + | </div> | |||
| 149 | + | <div> | |||
| 150 | + | <dt> | |||
| 151 | + | <kbd>/</kbd> | |||
| 152 | + | </dt> | |||
| 153 | + | <dd>focus the filter</dd> | |||
| 154 | + | </div> | |||
| 155 | + | <div> | |||
| 156 | + | <dt> | |||
| 157 | + | <kbd>g h</kbd> | |||
| 158 | + | <kbd>g t</kbd> | |||
| 159 | + | </dt> | |||
| 160 | + | <dd>go home / tree</dd> | |||
| 161 | + | </div> | |||
| 162 | + | <div> | |||
| 163 | + | <dt> | |||
| 164 | + | <kbd>g c</kbd> | |||
| 165 | + | <kbd>g r</kbd> | |||
| 166 | + | </dt> | |||
| 167 | + | <dd>go commits / refs</dd> | |||
| 168 | + | </div> | |||
| 169 | + | <div> | |||
| 170 | + | <dt> | |||
| 171 | + | <kbd>b</kbd> | |||
| 172 | + | </dt> | |||
| 173 | + | <dd>switch ref (branches, tags)</dd> | |||
| 174 | + | </div> | |||
| 175 | + | <div> | |||
| 176 | + | <dt> | |||
| 177 | + | <kbd>:</kbd> | |||
| 178 | + | <kbd>f</kbd> | |||
| 179 | + | </dt> | |||
| 180 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 181 | + | </div> | |||
| 182 | + | <div> | |||
| 183 | + | <dt> | |||
| 184 | + | <kbd>y</kbd> | |||
| 185 | + | </dt> | |||
| 186 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 187 | + | </div> | |||
| 188 | + | <div> | |||
| 189 | + | <dt> | |||
| 190 | + | <kbd>s</kbd> | |||
| 191 | + | <kbd>t</kbd> | |||
| 192 | + | <kbd>c</kbd> | |||
| 193 | + | <kbd>r</kbd> | |||
| 194 | + | </dt> | |||
| 195 | + | <dd>repo sections (when shown)</dd> | |||
| 196 | + | </div> | |||
| 197 | + | <div> | |||
| 198 | + | <dt> | |||
| 199 | + | <kbd>d</kbd> | |||
| 200 | + | <kbd>u</kbd> | |||
| 201 | + | <kbd>x</kbd> | |||
| 202 | + | </dt> | |||
| 203 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 204 | + | </div> | |||
| 205 | + | <div> | |||
| 206 | + | <dt> | |||
| 207 | + | <kbd>esc</kbd> | |||
| 208 | + | </dt> | |||
| 209 | + | <dd>close panel</dd> | |||
| 210 | + | </div> | |||
| 211 | + | </dl> | |||
| 212 | + | </dialog> | |||
| 213 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 214 | + | <div class="panel-head"> | |||
| 215 | + | <label for="refs-filter">ref></label> | |||
| 216 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |||
| 217 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 218 | + | </div> | |||
| 219 | + | <div class="panel-cols" data-picklist> | |||
| 220 | + | <section aria-label="Branches"> | |||
| 221 | + | <h2>branches <span class="mute">1</span> | |||
| 222 | + | </h2> | |||
| 223 | + | <ul> | |||
| 224 | + | <li> | |||
| 225 | + | <a href="/long/tree/main" aria-current="true"> | |||
| 226 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 227 | + | </li> | |||
| 228 | + | </ul> | |||
| 229 | + | </section> | |||
| 230 | + | <section aria-label="Tags"> | |||
| 231 | + | <h2>tags <span class="mute">0</span> | |||
| 232 | + | </h2> | |||
| 233 | + | <ul> | |||
| 234 | + | </ul> | |||
| 235 | + | </section> | |||
| 236 | + | </div> | |||
| 237 | + | </dialog> | |||
| 238 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 239 | + | <div class="panel-head"> | |||
| 240 | + | <label for="cmd-q" class="hi">:</label> | |||
| 241 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 242 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 243 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 244 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 245 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 246 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 247 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 248 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 249 | + | </div> | |||
| 250 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 251 | + | <li data-k="go home" data-cursor> | |||
| 252 | + | <a href="/" tabindex="-1"> | |||
| 253 | + | <span class="k">go</span>home</a> | |||
| 254 | + | </li> | |||
| 255 | + | <li data-k="section summary"> | |||
| 256 | + | <a href="/long" tabindex="-1"> | |||
| 257 | + | <span class="k">section</span>summary</a> | |||
| 258 | + | </li> | |||
| 259 | + | <li data-k="section tree"> | |||
| 260 | + | <a href="/long/tree/main" tabindex="-1"> | |||
| 261 | + | <span class="k">section</span>tree</a> | |||
| 262 | + | </li> | |||
| 263 | + | <li data-k="section commits"> | |||
| 264 | + | <a href="/long/commits/main" tabindex="-1"> | |||
| 265 | + | <span class="k">section</span>commits</a> | |||
| 266 | + | </li> | |||
| 267 | + | <li data-k="section refs"> | |||
| 268 | + | <a href="/long/refs" tabindex="-1"> | |||
| 269 | + | <span class="k">section</span>refs</a> | |||
| 270 | + | </li> | |||
| 271 | + | <li data-k="branch main"> | |||
| 272 | + | <a href="/long/tree/main" tabindex="-1"> | |||
| 273 | + | <span class="k">branch</span>main</a> | |||
| 274 | + | </li> | |||
| 275 | + | <li data-k="repo diffs"> | |||
| 276 | + | <a href="/diffs" tabindex="-1"> | |||
| 277 | + | <span class="k">repo</span>diffs</a> | |||
| 278 | + | </li> | |||
| 279 | + | </ul> | |||
| 280 | + | </dialog> | |||
| 281 | + | <footer class="bar status" aria-label="Status"> | |||
| 282 | + | <span class="mode">commits</span> | |||
| 283 | + | <span class="st-ctx">main · 2 commits</span> | |||
| 284 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 285 | + | <span class="hi">:</span> go to…</button> | |||
| 286 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 287 | + | </span> | |||
| 288 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |||
| 289 | + | <span class="st-src"> | |||
| 290 | + | <span class="st-ver">ermite devel · </span> | |||
| 291 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 292 | + | </span> | |||
| 293 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 294 | + | <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> | |||
| 295 | + | </footer> | |||
| 296 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 297 | + | if (q('dialog[open]').count) return; | |||
| 298 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 299 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 300 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 301 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 302 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 303 | + | })(1); | |||
| 304 | + | k-k from:body -> ((d) => { | |||
| 305 | + | if (q('dialog[open]').count) return; | |||
| 306 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 307 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 308 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 309 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 310 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 311 | + | })(-1); | |||
| 312 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 313 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 314 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 315 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 316 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 317 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |||
| 318 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 319 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 320 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 321 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 322 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 323 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 324 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 325 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 326 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 327 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 328 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 329 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 330 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 331 | + | k-permalink from:body -> { | |||
| 332 | + | let b = document.body.dataset; | |||
| 333 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 334 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 335 | + | navigator.clipboard?.writeText(location.href); | |||
| 336 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 337 | + | }"> | |||
| 338 | + | </div> | |||
| 339 | + | </body> | |||
| 340 | + | </html> | |||
A cmd/ermite/testdata/web/commits-path.html +355 -0
| @@ -0,0 +1,355 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>history of a.txt · long · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/long">long</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/long/commits/main">commits</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/long/commits/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <span aria-current="page">a.txt</span> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 53 | + | </li> | |
| 54 | + | </ul> | |
| 55 | + | </nav> | |
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work has-rail"> | |
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 61 | + | <div class="filter"> | |
| 62 | + | <label for="commit-filter">filter></label> | |
| 63 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 64 | + | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 65 | + | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |
| 66 | + | </div> | |
| 67 | + | <p class="lede dim">history of <code>a.txt</code> at main</p> | |
| 68 | + | <div class="days" id="commits-head"> | |
| 69 | + | <section aria-label="2026-03-16"> | |
| 70 | + | <h2 class="rule-h"> | |
| 71 | + | <span>2026-03-16</span> | |
| 72 | + | </h2> | |
| 73 | + | <table class="list commits" aria-label="Commits"> | |
| 74 | + | <tbody data-rows> | |
| 75 | + | <tr id="c-SHA7" data-cursor> | |
| 76 | + | <td class="cur" aria-hidden="true"> | |
| 77 | + | </td> | |
| 78 | + | <td class="c-sha mute">SHA7</td> | |
| 79 | + | <td class="c-subject"> | |
| 80 | + | <a href="/long/commit/SHA40">commit number 30</a> | |
| 81 | + | </td> | |
| 82 | + | <td class="c-who dim">T</td> | |
| 83 | + | <td class="c-when nowrap dim"> | |
| 84 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 85 | + | </td> | |
| 86 | + | </tr> | |
| 87 | + | </tbody> | |
| 88 | + | </table> | |
| 89 | + | </section> | |
| 90 | + | <section aria-label="2026-03-02"> | |
| 91 | + | <h2 class="rule-h"> | |
| 92 | + | <span>2026-03-02</span> | |
| 93 | + | </h2> | |
| 94 | + | <table class="list commits" aria-label="Commits"> | |
| 95 | + | <tbody data-rows> | |
| 96 | + | <tr id="c-SHA7"> | |
| 97 | + | <td class="cur" aria-hidden="true"> | |
| 98 | + | </td> | |
| 99 | + | <td class="c-sha mute">SHA7</td> | |
| 100 | + | <td class="c-subject"> | |
| 101 | + | <a href="/long/commit/SHA40">commit number 3</a> | |
| 102 | + | </td> | |
| 103 | + | <td class="c-who dim">T</td> | |
| 104 | + | <td class="c-when nowrap dim"> | |
| 105 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 106 | + | </td> | |
| 107 | + | </tr> | |
| 108 | + | </tbody> | |
| 109 | + | </table> | |
| 110 | + | </section> | |
| 111 | + | </div> | |
| 112 | + | </main> | |
| 113 | + | <aside class="rail" aria-label="Context"> | |
| 114 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 115 | + | <summary>history · refs on this page</summary> | |
| 116 | + | <section aria-labelledby="r-hist"> | |
| 117 | + | <h2 id="r-hist">history</h2> | |
| 118 | + | <dl class="kv"> | |
| 119 | + | <dt>ref</dt> | |
| 120 | + | <dd>main</dd> | |
| 121 | + | <dt>on page</dt> | |
| 122 | + | <dd>2</dd> | |
| 123 | + | <dt>path</dt> | |
| 124 | + | <dd>a.txt</dd> | |
| 125 | + | <dt>file</dt> | |
| 126 | + | <dd> | |
| 127 | + | <a href="/long/blob/main/a.txt">open</a> | |
| 128 | + | </dd> | |
| 129 | + | </dl> | |
| 130 | + | </section> | |
| 131 | + | <section aria-labelledby="r-refs"> | |
| 132 | + | <h2 id="r-refs">refs on this page</h2> | |
| 133 | + | <p class="dim">no tag or branch head here</p> | |
| 134 | + | </section> | |
| 135 | + | </details> | |
| 136 | + | </aside> | |
| 137 | + | </div> | |
| 138 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 139 | + | <div class="panel-head"> | |
| 140 | + | <span>keys</span> | |
| 141 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 142 | + | </div> | |
| 143 | + | <dl class="keys"> | |
| 144 | + | <div> | |
| 145 | + | <dt> | |
| 146 | + | <kbd>j</kbd> | |
| 147 | + | <kbd>k</kbd> | |
| 148 | + | </dt> | |
| 149 | + | <dd>move cursor down / up</dd> | |
| 150 | + | </div> | |
| 151 | + | <div> | |
| 152 | + | <dt> | |
| 153 | + | <kbd>enter</kbd> | |
| 154 | + | </dt> | |
| 155 | + | <dd>open row under cursor</dd> | |
| 156 | + | </div> | |
| 157 | + | <div> | |
| 158 | + | <dt> | |
| 159 | + | <kbd>g</kbd> | |
| 160 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 161 | + | </dt> | |
| 162 | + | <dd>first / last row</dd> | |
| 163 | + | </div> | |
| 164 | + | <div> | |
| 165 | + | <dt> | |
| 166 | + | <kbd>/</kbd> | |
| 167 | + | </dt> | |
| 168 | + | <dd>focus the filter</dd> | |
| 169 | + | </div> | |
| 170 | + | <div> | |
| 171 | + | <dt> | |
| 172 | + | <kbd>g h</kbd> | |
| 173 | + | <kbd>g t</kbd> | |
| 174 | + | </dt> | |
| 175 | + | <dd>go home / tree</dd> | |
| 176 | + | </div> | |
| 177 | + | <div> | |
| 178 | + | <dt> | |
| 179 | + | <kbd>g c</kbd> | |
| 180 | + | <kbd>g r</kbd> | |
| 181 | + | </dt> | |
| 182 | + | <dd>go commits / refs</dd> | |
| 183 | + | </div> | |
| 184 | + | <div> | |
| 185 | + | <dt> | |
| 186 | + | <kbd>b</kbd> | |
| 187 | + | </dt> | |
| 188 | + | <dd>switch ref (branches, tags)</dd> | |
| 189 | + | </div> | |
| 190 | + | <div> | |
| 191 | + | <dt> | |
| 192 | + | <kbd>:</kbd> | |
| 193 | + | <kbd>f</kbd> | |
| 194 | + | </dt> | |
| 195 | + | <dd>go to: section, ref, file, repo</dd> | |
| 196 | + | </div> | |
| 197 | + | <div> | |
| 198 | + | <dt> | |
| 199 | + | <kbd>y</kbd> | |
| 200 | + | </dt> | |
| 201 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 202 | + | </div> | |
| 203 | + | <div> | |
| 204 | + | <dt> | |
| 205 | + | <kbd>s</kbd> | |
| 206 | + | <kbd>t</kbd> | |
| 207 | + | <kbd>c</kbd> | |
| 208 | + | <kbd>r</kbd> | |
| 209 | + | </dt> | |
| 210 | + | <dd>repo sections (when shown)</dd> | |
| 211 | + | </div> | |
| 212 | + | <div> | |
| 213 | + | <dt> | |
| 214 | + | <kbd>d</kbd> | |
| 215 | + | <kbd>u</kbd> | |
| 216 | + | <kbd>x</kbd> | |
| 217 | + | </dt> | |
| 218 | + | <dd>commit: split / unified / collapse all</dd> | |
| 219 | + | </div> | |
| 220 | + | <div> | |
| 221 | + | <dt> | |
| 222 | + | <kbd>esc</kbd> | |
| 223 | + | </dt> | |
| 224 | + | <dd>close panel</dd> | |
| 225 | + | </div> | |
| 226 | + | </dl> | |
| 227 | + | </dialog> | |
| 228 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 229 | + | <div class="panel-head"> | |
| 230 | + | <label for="refs-filter">ref></label> | |
| 231 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 232 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 233 | + | </div> | |
| 234 | + | <div class="panel-cols" data-picklist> | |
| 235 | + | <section aria-label="Branches"> | |
| 236 | + | <h2>branches <span class="mute">1</span> | |
| 237 | + | </h2> | |
| 238 | + | <ul> | |
| 239 | + | <li> | |
| 240 | + | <a href="/long/tree/main" aria-current="true"> | |
| 241 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 242 | + | </li> | |
| 243 | + | </ul> | |
| 244 | + | </section> | |
| 245 | + | <section aria-label="Tags"> | |
| 246 | + | <h2>tags <span class="mute">0</span> | |
| 247 | + | </h2> | |
| 248 | + | <ul> | |
| 249 | + | </ul> | |
| 250 | + | </section> | |
| 251 | + | </div> | |
| 252 | + | </dialog> | |
| 253 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 254 | + | <div class="panel-head"> | |
| 255 | + | <label for="cmd-q" class="hi">:</label> | |
| 256 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 257 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 258 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 259 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 260 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 261 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 262 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 263 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 264 | + | </div> | |
| 265 | + | <ul id="cmd-list" class="cmd-list"> | |
| 266 | + | <li data-k="go home" data-cursor> | |
| 267 | + | <a href="/" tabindex="-1"> | |
| 268 | + | <span class="k">go</span>home</a> | |
| 269 | + | </li> | |
| 270 | + | <li data-k="section summary"> | |
| 271 | + | <a href="/long" tabindex="-1"> | |
| 272 | + | <span class="k">section</span>summary</a> | |
| 273 | + | </li> | |
| 274 | + | <li data-k="section tree"> | |
| 275 | + | <a href="/long/tree/main" tabindex="-1"> | |
| 276 | + | <span class="k">section</span>tree</a> | |
| 277 | + | </li> | |
| 278 | + | <li data-k="section commits"> | |
| 279 | + | <a href="/long/commits/main" tabindex="-1"> | |
| 280 | + | <span class="k">section</span>commits</a> | |
| 281 | + | </li> | |
| 282 | + | <li data-k="section refs"> | |
| 283 | + | <a href="/long/refs" tabindex="-1"> | |
| 284 | + | <span class="k">section</span>refs</a> | |
| 285 | + | </li> | |
| 286 | + | <li data-k="branch main"> | |
| 287 | + | <a href="/long/tree/main" tabindex="-1"> | |
| 288 | + | <span class="k">branch</span>main</a> | |
| 289 | + | </li> | |
| 290 | + | <li data-k="repo diffs"> | |
| 291 | + | <a href="/diffs" tabindex="-1"> | |
| 292 | + | <span class="k">repo</span>diffs</a> | |
| 293 | + | </li> | |
| 294 | + | </ul> | |
| 295 | + | </dialog> | |
| 296 | + | <footer class="bar status" aria-label="Status"> | |
| 297 | + | <span class="mode">commits</span> | |
| 298 | + | <span class="st-ctx">main · history of a.txt · 2 commits</span> | |
| 299 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 300 | + | <span class="hi">:</span> go to…</button> | |
| 301 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 302 | + | </span> | |
| 303 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |
| 304 | + | <span class="st-src"> | |
| 305 | + | <span class="st-ver">ermite devel · </span> | |
| 306 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 307 | + | </span> | |
| 308 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 309 | + | <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> | |
| 310 | + | </footer> | |
| 311 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 312 | + | if (q('dialog[open]').count) return; | |
| 313 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 314 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 315 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 316 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 317 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 318 | + | })(1); | |
| 319 | + | k-k from:body -> ((d) => { | |
| 320 | + | if (q('dialog[open]').count) return; | |
| 321 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 322 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 323 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 324 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 325 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 326 | + | })(-1); | |
| 327 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 328 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 329 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 330 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 331 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 332 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 333 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 334 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 335 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 336 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 337 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 338 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 339 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 340 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 341 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 342 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 343 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 344 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 345 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 346 | + | k-permalink from:body -> { | |
| 347 | + | let b = document.body.dataset; | |
| 348 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 349 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 350 | + | navigator.clipboard?.writeText(location.href); | |
| 351 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 352 | + | }"> | |
| 353 | + | </div> | |
| 354 | + | </body> | |
| 355 | + | </html> | |
| @@ -0,0 +1,355 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>history of a.txt · long · example.test</title> | |||
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 10 | + | </script> | |||
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 12 | + | </script> | |||
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 14 | + | </script> | |||
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 16 | + | </script> | |||
| 17 | + | </head> | |||
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |||
| 19 | + | <a class="skip" href="#main">skip to content</a> | |||
| 20 | + | <header class="bar top"> | |||
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |||
| 22 | + | <span class="fade" aria-hidden="true"> | |||
| 23 | + | </span> | |||
| 24 | + | <nav class="crumbs" aria-label="Path"> | |||
| 25 | + | <ol> | |||
| 26 | + | <li> | |||
| 27 | + | <a href="/long">long</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/long/commits/main">commits</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/long/commits/main">main</a> | |||
| 34 | + | </li> | |||
| 35 | + | <li> | |||
| 36 | + | <span aria-current="page">a.txt</span> | |||
| 37 | + | </li> | |||
| 38 | + | </ol> | |||
| 39 | + | </nav> | |||
| 40 | + | <nav class="secs" aria-label="Sections"> | |||
| 41 | + | <ul> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |||
| 50 | + | </li> | |||
| 51 | + | <li> | |||
| 52 | + | <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |||
| 53 | + | </li> | |||
| 54 | + | </ul> | |||
| 55 | + | </nav> | |||
| 56 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |||
| 57 | + | </button> | |||
| 58 | + | </header> | |||
| 59 | + | <div class="work has-rail"> | |||
| 60 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 61 | + | <div class="filter"> | |||
| 62 | + | <label for="commit-filter">filter></label> | |||
| 63 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |||
| 64 | + | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 65 | + | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')"> | |||
| 66 | + | </div> | |||
| 67 | + | <p class="lede dim">history of <code>a.txt</code> at main</p> | |||
| 68 | + | <div class="days" id="commits-head"> | |||
| 69 | + | <section aria-label="2026-03-16"> | |||
| 70 | + | <h2 class="rule-h"> | |||
| 71 | + | <span>2026-03-16</span> | |||
| 72 | + | </h2> | |||
| 73 | + | <table class="list commits" aria-label="Commits"> | |||
| 74 | + | <tbody data-rows> | |||
| 75 | + | <tr id="c-SHA7" data-cursor> | |||
| 76 | + | <td class="cur" aria-hidden="true"> | |||
| 77 | + | </td> | |||
| 78 | + | <td class="c-sha mute">SHA7</td> | |||
| 79 | + | <td class="c-subject"> | |||
| 80 | + | <a href="/long/commit/SHA40">commit number 30</a> | |||
| 81 | + | </td> | |||
| 82 | + | <td class="c-who dim">T</td> | |||
| 83 | + | <td class="c-when nowrap dim"> | |||
| 84 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 85 | + | </td> | |||
| 86 | + | </tr> | |||
| 87 | + | </tbody> | |||
| 88 | + | </table> | |||
| 89 | + | </section> | |||
| 90 | + | <section aria-label="2026-03-02"> | |||
| 91 | + | <h2 class="rule-h"> | |||
| 92 | + | <span>2026-03-02</span> | |||
| 93 | + | </h2> | |||
| 94 | + | <table class="list commits" aria-label="Commits"> | |||
| 95 | + | <tbody data-rows> | |||
| 96 | + | <tr id="c-SHA7"> | |||
| 97 | + | <td class="cur" aria-hidden="true"> | |||
| 98 | + | </td> | |||
| 99 | + | <td class="c-sha mute">SHA7</td> | |||
| 100 | + | <td class="c-subject"> | |||
| 101 | + | <a href="/long/commit/SHA40">commit number 3</a> | |||
| 102 | + | </td> | |||
| 103 | + | <td class="c-who dim">T</td> | |||
| 104 | + | <td class="c-when nowrap dim"> | |||
| 105 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 106 | + | </td> | |||
| 107 | + | </tr> | |||
| 108 | + | </tbody> | |||
| 109 | + | </table> | |||
| 110 | + | </section> | |||
| 111 | + | </div> | |||
| 112 | + | </main> | |||
| 113 | + | <aside class="rail" aria-label="Context"> | |||
| 114 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 115 | + | <summary>history · refs on this page</summary> | |||
| 116 | + | <section aria-labelledby="r-hist"> | |||
| 117 | + | <h2 id="r-hist">history</h2> | |||
| 118 | + | <dl class="kv"> | |||
| 119 | + | <dt>ref</dt> | |||
| 120 | + | <dd>main</dd> | |||
| 121 | + | <dt>on page</dt> | |||
| 122 | + | <dd>2</dd> | |||
| 123 | + | <dt>path</dt> | |||
| 124 | + | <dd>a.txt</dd> | |||
| 125 | + | <dt>file</dt> | |||
| 126 | + | <dd> | |||
| 127 | + | <a href="/long/blob/main/a.txt">open</a> | |||
| 128 | + | </dd> | |||
| 129 | + | </dl> | |||
| 130 | + | </section> | |||
| 131 | + | <section aria-labelledby="r-refs"> | |||
| 132 | + | <h2 id="r-refs">refs on this page</h2> | |||
| 133 | + | <p class="dim">no tag or branch head here</p> | |||
| 134 | + | </section> | |||
| 135 | + | </details> | |||
| 136 | + | </aside> | |||
| 137 | + | </div> | |||
| 138 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 139 | + | <div class="panel-head"> | |||
| 140 | + | <span>keys</span> | |||
| 141 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 142 | + | </div> | |||
| 143 | + | <dl class="keys"> | |||
| 144 | + | <div> | |||
| 145 | + | <dt> | |||
| 146 | + | <kbd>j</kbd> | |||
| 147 | + | <kbd>k</kbd> | |||
| 148 | + | </dt> | |||
| 149 | + | <dd>move cursor down / up</dd> | |||
| 150 | + | </div> | |||
| 151 | + | <div> | |||
| 152 | + | <dt> | |||
| 153 | + | <kbd>enter</kbd> | |||
| 154 | + | </dt> | |||
| 155 | + | <dd>open row under cursor</dd> | |||
| 156 | + | </div> | |||
| 157 | + | <div> | |||
| 158 | + | <dt> | |||
| 159 | + | <kbd>g</kbd> | |||
| 160 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 161 | + | </dt> | |||
| 162 | + | <dd>first / last row</dd> | |||
| 163 | + | </div> | |||
| 164 | + | <div> | |||
| 165 | + | <dt> | |||
| 166 | + | <kbd>/</kbd> | |||
| 167 | + | </dt> | |||
| 168 | + | <dd>focus the filter</dd> | |||
| 169 | + | </div> | |||
| 170 | + | <div> | |||
| 171 | + | <dt> | |||
| 172 | + | <kbd>g h</kbd> | |||
| 173 | + | <kbd>g t</kbd> | |||
| 174 | + | </dt> | |||
| 175 | + | <dd>go home / tree</dd> | |||
| 176 | + | </div> | |||
| 177 | + | <div> | |||
| 178 | + | <dt> | |||
| 179 | + | <kbd>g c</kbd> | |||
| 180 | + | <kbd>g r</kbd> | |||
| 181 | + | </dt> | |||
| 182 | + | <dd>go commits / refs</dd> | |||
| 183 | + | </div> | |||
| 184 | + | <div> | |||
| 185 | + | <dt> | |||
| 186 | + | <kbd>b</kbd> | |||
| 187 | + | </dt> | |||
| 188 | + | <dd>switch ref (branches, tags)</dd> | |||
| 189 | + | </div> | |||
| 190 | + | <div> | |||
| 191 | + | <dt> | |||
| 192 | + | <kbd>:</kbd> | |||
| 193 | + | <kbd>f</kbd> | |||
| 194 | + | </dt> | |||
| 195 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 196 | + | </div> | |||
| 197 | + | <div> | |||
| 198 | + | <dt> | |||
| 199 | + | <kbd>y</kbd> | |||
| 200 | + | </dt> | |||
| 201 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 202 | + | </div> | |||
| 203 | + | <div> | |||
| 204 | + | <dt> | |||
| 205 | + | <kbd>s</kbd> | |||
| 206 | + | <kbd>t</kbd> | |||
| 207 | + | <kbd>c</kbd> | |||
| 208 | + | <kbd>r</kbd> | |||
| 209 | + | </dt> | |||
| 210 | + | <dd>repo sections (when shown)</dd> | |||
| 211 | + | </div> | |||
| 212 | + | <div> | |||
| 213 | + | <dt> | |||
| 214 | + | <kbd>d</kbd> | |||
| 215 | + | <kbd>u</kbd> | |||
| 216 | + | <kbd>x</kbd> | |||
| 217 | + | </dt> | |||
| 218 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 219 | + | </div> | |||
| 220 | + | <div> | |||
| 221 | + | <dt> | |||
| 222 | + | <kbd>esc</kbd> | |||
| 223 | + | </dt> | |||
| 224 | + | <dd>close panel</dd> | |||
| 225 | + | </div> | |||
| 226 | + | </dl> | |||
| 227 | + | </dialog> | |||
| 228 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 229 | + | <div class="panel-head"> | |||
| 230 | + | <label for="refs-filter">ref></label> | |||
| 231 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |||
| 232 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 233 | + | </div> | |||
| 234 | + | <div class="panel-cols" data-picklist> | |||
| 235 | + | <section aria-label="Branches"> | |||
| 236 | + | <h2>branches <span class="mute">1</span> | |||
| 237 | + | </h2> | |||
| 238 | + | <ul> | |||
| 239 | + | <li> | |||
| 240 | + | <a href="/long/tree/main" aria-current="true"> | |||
| 241 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 242 | + | </li> | |||
| 243 | + | </ul> | |||
| 244 | + | </section> | |||
| 245 | + | <section aria-label="Tags"> | |||
| 246 | + | <h2>tags <span class="mute">0</span> | |||
| 247 | + | </h2> | |||
| 248 | + | <ul> | |||
| 249 | + | </ul> | |||
| 250 | + | </section> | |||
| 251 | + | </div> | |||
| 252 | + | </dialog> | |||
| 253 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 254 | + | <div class="panel-head"> | |||
| 255 | + | <label for="cmd-q" class="hi">:</label> | |||
| 256 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 257 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 258 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 259 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 260 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 261 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 262 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 263 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 264 | + | </div> | |||
| 265 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 266 | + | <li data-k="go home" data-cursor> | |||
| 267 | + | <a href="/" tabindex="-1"> | |||
| 268 | + | <span class="k">go</span>home</a> | |||
| 269 | + | </li> | |||
| 270 | + | <li data-k="section summary"> | |||
| 271 | + | <a href="/long" tabindex="-1"> | |||
| 272 | + | <span class="k">section</span>summary</a> | |||
| 273 | + | </li> | |||
| 274 | + | <li data-k="section tree"> | |||
| 275 | + | <a href="/long/tree/main" tabindex="-1"> | |||
| 276 | + | <span class="k">section</span>tree</a> | |||
| 277 | + | </li> | |||
| 278 | + | <li data-k="section commits"> | |||
| 279 | + | <a href="/long/commits/main" tabindex="-1"> | |||
| 280 | + | <span class="k">section</span>commits</a> | |||
| 281 | + | </li> | |||
| 282 | + | <li data-k="section refs"> | |||
| 283 | + | <a href="/long/refs" tabindex="-1"> | |||
| 284 | + | <span class="k">section</span>refs</a> | |||
| 285 | + | </li> | |||
| 286 | + | <li data-k="branch main"> | |||
| 287 | + | <a href="/long/tree/main" tabindex="-1"> | |||
| 288 | + | <span class="k">branch</span>main</a> | |||
| 289 | + | </li> | |||
| 290 | + | <li data-k="repo diffs"> | |||
| 291 | + | <a href="/diffs" tabindex="-1"> | |||
| 292 | + | <span class="k">repo</span>diffs</a> | |||
| 293 | + | </li> | |||
| 294 | + | </ul> | |||
| 295 | + | </dialog> | |||
| 296 | + | <footer class="bar status" aria-label="Status"> | |||
| 297 | + | <span class="mode">commits</span> | |||
| 298 | + | <span class="st-ctx">main · history of a.txt · 2 commits</span> | |||
| 299 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 300 | + | <span class="hi">:</span> go to…</button> | |||
| 301 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 302 | + | </span> | |||
| 303 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |||
| 304 | + | <span class="st-src"> | |||
| 305 | + | <span class="st-ver">ermite devel · </span> | |||
| 306 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 307 | + | </span> | |||
| 308 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 309 | + | <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> | |||
| 310 | + | </footer> | |||
| 311 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 312 | + | if (q('dialog[open]').count) return; | |||
| 313 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 314 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 315 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 316 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 317 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 318 | + | })(1); | |||
| 319 | + | k-k from:body -> ((d) => { | |||
| 320 | + | if (q('dialog[open]').count) return; | |||
| 321 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 322 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 323 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 324 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 325 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 326 | + | })(-1); | |||
| 327 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 328 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 329 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 330 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 331 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 332 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |||
| 333 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 334 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 335 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 336 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 337 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 338 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 339 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 340 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 341 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 342 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 343 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 344 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 345 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 346 | + | k-permalink from:body -> { | |||
| 347 | + | let b = document.body.dataset; | |||
| 348 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 349 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 350 | + | navigator.clipboard?.writeText(location.href); | |||
| 351 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 352 | + | }"> | |||
| 353 | + | </div> | |||
| 354 | + | </body> | |||
| 355 | + | </html> | |||
A cmd/ermite/testdata/web/commits-root.html +1135 -0
| @@ -0,0 +1,1135 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>commits · long · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/long">long</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/long/commits/main">commits</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/long/commits/main" aria-current="page">main</a> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <div class="filter"> | |
| 59 | + | <label for="commit-filter">filter></label> | |
| 60 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 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')"> | |
| 63 | + | </div> | |
| 64 | + | <div class="days" id="commits-head"> | |
| 65 | + | <section aria-label="2026-03-26"> | |
| 66 | + | <h2 class="rule-h"> | |
| 67 | + | <span>2026-03-26</span> | |
| 68 | + | </h2> | |
| 69 | + | <table class="list commits" aria-label="Commits"> | |
| 70 | + | <tbody data-rows> | |
| 71 | + | <tr id="c-SHA7" data-cursor> | |
| 72 | + | <td class="cur" aria-hidden="true"> | |
| 73 | + | </td> | |
| 74 | + | <td class="c-sha mute">SHA7</td> | |
| 75 | + | <td class="c-subject"> | |
| 76 | + | <a href="/long/commit/SHA40">commit number 51</a> | |
| 77 | + | <span class="tagged">[main]</span> | |
| 78 | + | </td> | |
| 79 | + | <td class="c-who dim">T</td> | |
| 80 | + | <td class="c-when nowrap dim"> | |
| 81 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 82 | + | </td> | |
| 83 | + | </tr> | |
| 84 | + | <tr id="c-SHA7"> | |
| 85 | + | <td class="cur" aria-hidden="true"> | |
| 86 | + | </td> | |
| 87 | + | <td class="c-sha mute">SHA7</td> | |
| 88 | + | <td class="c-subject"> | |
| 89 | + | <a href="/long/commit/SHA40">commit number 50</a> | |
| 90 | + | </td> | |
| 91 | + | <td class="c-who dim">T</td> | |
| 92 | + | <td class="c-when nowrap dim"> | |
| 93 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 94 | + | </td> | |
| 95 | + | </tr> | |
| 96 | + | </tbody> | |
| 97 | + | </table> | |
| 98 | + | </section> | |
| 99 | + | <section aria-label="2026-03-25"> | |
| 100 | + | <h2 class="rule-h"> | |
| 101 | + | <span>2026-03-25</span> | |
| 102 | + | </h2> | |
| 103 | + | <table class="list commits" aria-label="Commits"> | |
| 104 | + | <tbody data-rows> | |
| 105 | + | <tr id="c-SHA7"> | |
| 106 | + | <td class="cur" aria-hidden="true"> | |
| 107 | + | </td> | |
| 108 | + | <td class="c-sha mute">SHA7</td> | |
| 109 | + | <td class="c-subject"> | |
| 110 | + | <a href="/long/commit/SHA40">commit number 49</a> | |
| 111 | + | </td> | |
| 112 | + | <td class="c-who dim">T</td> | |
| 113 | + | <td class="c-when nowrap dim"> | |
| 114 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 115 | + | </td> | |
| 116 | + | </tr> | |
| 117 | + | <tr id="c-SHA7"> | |
| 118 | + | <td class="cur" aria-hidden="true"> | |
| 119 | + | </td> | |
| 120 | + | <td class="c-sha mute">SHA7</td> | |
| 121 | + | <td class="c-subject"> | |
| 122 | + | <a href="/long/commit/SHA40">commit number 48</a> | |
| 123 | + | </td> | |
| 124 | + | <td class="c-who dim">T</td> | |
| 125 | + | <td class="c-when nowrap dim"> | |
| 126 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 127 | + | </td> | |
| 128 | + | </tr> | |
| 129 | + | </tbody> | |
| 130 | + | </table> | |
| 131 | + | </section> | |
| 132 | + | <section aria-label="2026-03-24"> | |
| 133 | + | <h2 class="rule-h"> | |
| 134 | + | <span>2026-03-24</span> | |
| 135 | + | </h2> | |
| 136 | + | <table class="list commits" aria-label="Commits"> | |
| 137 | + | <tbody data-rows> | |
| 138 | + | <tr id="c-SHA7"> | |
| 139 | + | <td class="cur" aria-hidden="true"> | |
| 140 | + | </td> | |
| 141 | + | <td class="c-sha mute">SHA7</td> | |
| 142 | + | <td class="c-subject"> | |
| 143 | + | <a href="/long/commit/SHA40">commit number 47</a> | |
| 144 | + | </td> | |
| 145 | + | <td class="c-who dim">T</td> | |
| 146 | + | <td class="c-when nowrap dim"> | |
| 147 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 148 | + | </td> | |
| 149 | + | </tr> | |
| 150 | + | <tr id="c-SHA7"> | |
| 151 | + | <td class="cur" aria-hidden="true"> | |
| 152 | + | </td> | |
| 153 | + | <td class="c-sha mute">SHA7</td> | |
| 154 | + | <td class="c-subject"> | |
| 155 | + | <a href="/long/commit/SHA40">commit number 46</a> | |
| 156 | + | </td> | |
| 157 | + | <td class="c-who dim">T</td> | |
| 158 | + | <td class="c-when nowrap dim"> | |
| 159 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 160 | + | </td> | |
| 161 | + | </tr> | |
| 162 | + | </tbody> | |
| 163 | + | </table> | |
| 164 | + | </section> | |
| 165 | + | <section aria-label="2026-03-23"> | |
| 166 | + | <h2 class="rule-h"> | |
| 167 | + | <span>2026-03-23</span> | |
| 168 | + | </h2> | |
| 169 | + | <table class="list commits" aria-label="Commits"> | |
| 170 | + | <tbody data-rows> | |
| 171 | + | <tr id="c-SHA7"> | |
| 172 | + | <td class="cur" aria-hidden="true"> | |
| 173 | + | </td> | |
| 174 | + | <td class="c-sha mute">SHA7</td> | |
| 175 | + | <td class="c-subject"> | |
| 176 | + | <a href="/long/commit/SHA40">commit number 45</a> | |
| 177 | + | </td> | |
| 178 | + | <td class="c-who dim">T</td> | |
| 179 | + | <td class="c-when nowrap dim"> | |
| 180 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 181 | + | </td> | |
| 182 | + | </tr> | |
| 183 | + | <tr id="c-SHA7"> | |
| 184 | + | <td class="cur" aria-hidden="true"> | |
| 185 | + | </td> | |
| 186 | + | <td class="c-sha mute">SHA7</td> | |
| 187 | + | <td class="c-subject"> | |
| 188 | + | <a href="/long/commit/SHA40">commit number 44</a> | |
| 189 | + | </td> | |
| 190 | + | <td class="c-who dim">T</td> | |
| 191 | + | <td class="c-when nowrap dim"> | |
| 192 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 193 | + | </td> | |
| 194 | + | </tr> | |
| 195 | + | </tbody> | |
| 196 | + | </table> | |
| 197 | + | </section> | |
| 198 | + | <section aria-label="2026-03-22"> | |
| 199 | + | <h2 class="rule-h"> | |
| 200 | + | <span>2026-03-22</span> | |
| 201 | + | </h2> | |
| 202 | + | <table class="list commits" aria-label="Commits"> | |
| 203 | + | <tbody data-rows> | |
| 204 | + | <tr id="c-SHA7"> | |
| 205 | + | <td class="cur" aria-hidden="true"> | |
| 206 | + | </td> | |
| 207 | + | <td class="c-sha mute">SHA7</td> | |
| 208 | + | <td class="c-subject"> | |
| 209 | + | <a href="/long/commit/SHA40">commit number 43</a> | |
| 210 | + | </td> | |
| 211 | + | <td class="c-who dim">T</td> | |
| 212 | + | <td class="c-when nowrap dim"> | |
| 213 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 214 | + | </td> | |
| 215 | + | </tr> | |
| 216 | + | <tr id="c-SHA7"> | |
| 217 | + | <td class="cur" aria-hidden="true"> | |
| 218 | + | </td> | |
| 219 | + | <td class="c-sha mute">SHA7</td> | |
| 220 | + | <td class="c-subject"> | |
| 221 | + | <a href="/long/commit/SHA40">commit number 42</a> | |
| 222 | + | </td> | |
| 223 | + | <td class="c-who dim">T</td> | |
| 224 | + | <td class="c-when nowrap dim"> | |
| 225 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 226 | + | </td> | |
| 227 | + | </tr> | |
| 228 | + | </tbody> | |
| 229 | + | </table> | |
| 230 | + | </section> | |
| 231 | + | <section aria-label="2026-03-21"> | |
| 232 | + | <h2 class="rule-h"> | |
| 233 | + | <span>2026-03-21</span> | |
| 234 | + | </h2> | |
| 235 | + | <table class="list commits" aria-label="Commits"> | |
| 236 | + | <tbody data-rows> | |
| 237 | + | <tr id="c-SHA7"> | |
| 238 | + | <td class="cur" aria-hidden="true"> | |
| 239 | + | </td> | |
| 240 | + | <td class="c-sha mute">SHA7</td> | |
| 241 | + | <td class="c-subject"> | |
| 242 | + | <a href="/long/commit/SHA40">commit number 41</a> | |
| 243 | + | </td> | |
| 244 | + | <td class="c-who dim">T</td> | |
| 245 | + | <td class="c-when nowrap dim"> | |
| 246 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 247 | + | </td> | |
| 248 | + | </tr> | |
| 249 | + | <tr id="c-SHA7"> | |
| 250 | + | <td class="cur" aria-hidden="true"> | |
| 251 | + | </td> | |
| 252 | + | <td class="c-sha mute">SHA7</td> | |
| 253 | + | <td class="c-subject"> | |
| 254 | + | <a href="/long/commit/SHA40">commit number 40</a> | |
| 255 | + | </td> | |
| 256 | + | <td class="c-who dim">T</td> | |
| 257 | + | <td class="c-when nowrap dim"> | |
| 258 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 259 | + | </td> | |
| 260 | + | </tr> | |
| 261 | + | </tbody> | |
| 262 | + | </table> | |
| 263 | + | </section> | |
| 264 | + | <section aria-label="2026-03-20"> | |
| 265 | + | <h2 class="rule-h"> | |
| 266 | + | <span>2026-03-20</span> | |
| 267 | + | </h2> | |
| 268 | + | <table class="list commits" aria-label="Commits"> | |
| 269 | + | <tbody data-rows> | |
| 270 | + | <tr id="c-SHA7"> | |
| 271 | + | <td class="cur" aria-hidden="true"> | |
| 272 | + | </td> | |
| 273 | + | <td class="c-sha mute">SHA7</td> | |
| 274 | + | <td class="c-subject"> | |
| 275 | + | <a href="/long/commit/SHA40">commit number 39</a> | |
| 276 | + | </td> | |
| 277 | + | <td class="c-who dim">T</td> | |
| 278 | + | <td class="c-when nowrap dim"> | |
| 279 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 280 | + | </td> | |
| 281 | + | </tr> | |
| 282 | + | <tr id="c-SHA7"> | |
| 283 | + | <td class="cur" aria-hidden="true"> | |
| 284 | + | </td> | |
| 285 | + | <td class="c-sha mute">SHA7</td> | |
| 286 | + | <td class="c-subject"> | |
| 287 | + | <a href="/long/commit/SHA40">commit number 38</a> | |
| 288 | + | </td> | |
| 289 | + | <td class="c-who dim">T</td> | |
| 290 | + | <td class="c-when nowrap dim"> | |
| 291 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 292 | + | </td> | |
| 293 | + | </tr> | |
| 294 | + | </tbody> | |
| 295 | + | </table> | |
| 296 | + | </section> | |
| 297 | + | <section aria-label="2026-03-19"> | |
| 298 | + | <h2 class="rule-h"> | |
| 299 | + | <span>2026-03-19</span> | |
| 300 | + | </h2> | |
| 301 | + | <table class="list commits" aria-label="Commits"> | |
| 302 | + | <tbody data-rows> | |
| 303 | + | <tr id="c-SHA7"> | |
| 304 | + | <td class="cur" aria-hidden="true"> | |
| 305 | + | </td> | |
| 306 | + | <td class="c-sha mute">SHA7</td> | |
| 307 | + | <td class="c-subject"> | |
| 308 | + | <a href="/long/commit/SHA40">commit number 37</a> | |
| 309 | + | </td> | |
| 310 | + | <td class="c-who dim">T</td> | |
| 311 | + | <td class="c-when nowrap dim"> | |
| 312 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 313 | + | </td> | |
| 314 | + | </tr> | |
| 315 | + | <tr id="c-SHA7"> | |
| 316 | + | <td class="cur" aria-hidden="true"> | |
| 317 | + | </td> | |
| 318 | + | <td class="c-sha mute">SHA7</td> | |
| 319 | + | <td class="c-subject"> | |
| 320 | + | <a href="/long/commit/SHA40">commit number 36</a> | |
| 321 | + | </td> | |
| 322 | + | <td class="c-who dim">T</td> | |
| 323 | + | <td class="c-when nowrap dim"> | |
| 324 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 325 | + | </td> | |
| 326 | + | </tr> | |
| 327 | + | </tbody> | |
| 328 | + | </table> | |
| 329 | + | </section> | |
| 330 | + | <section aria-label="2026-03-18"> | |
| 331 | + | <h2 class="rule-h"> | |
| 332 | + | <span>2026-03-18</span> | |
| 333 | + | </h2> | |
| 334 | + | <table class="list commits" aria-label="Commits"> | |
| 335 | + | <tbody data-rows> | |
| 336 | + | <tr id="c-SHA7"> | |
| 337 | + | <td class="cur" aria-hidden="true"> | |
| 338 | + | </td> | |
| 339 | + | <td class="c-sha mute">SHA7</td> | |
| 340 | + | <td class="c-subject"> | |
| 341 | + | <a href="/long/commit/SHA40">commit number 35</a> | |
| 342 | + | </td> | |
| 343 | + | <td class="c-who dim">T</td> | |
| 344 | + | <td class="c-when nowrap dim"> | |
| 345 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 346 | + | </td> | |
| 347 | + | </tr> | |
| 348 | + | <tr id="c-SHA7"> | |
| 349 | + | <td class="cur" aria-hidden="true"> | |
| 350 | + | </td> | |
| 351 | + | <td class="c-sha mute">SHA7</td> | |
| 352 | + | <td class="c-subject"> | |
| 353 | + | <a href="/long/commit/SHA40">commit number 34</a> | |
| 354 | + | </td> | |
| 355 | + | <td class="c-who dim">T</td> | |
| 356 | + | <td class="c-when nowrap dim"> | |
| 357 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 358 | + | </td> | |
| 359 | + | </tr> | |
| 360 | + | </tbody> | |
| 361 | + | </table> | |
| 362 | + | </section> | |
| 363 | + | <section aria-label="2026-03-17"> | |
| 364 | + | <h2 class="rule-h"> | |
| 365 | + | <span>2026-03-17</span> | |
| 366 | + | </h2> | |
| 367 | + | <table class="list commits" aria-label="Commits"> | |
| 368 | + | <tbody data-rows> | |
| 369 | + | <tr id="c-SHA7"> | |
| 370 | + | <td class="cur" aria-hidden="true"> | |
| 371 | + | </td> | |
| 372 | + | <td class="c-sha mute">SHA7</td> | |
| 373 | + | <td class="c-subject"> | |
| 374 | + | <a href="/long/commit/SHA40">commit number 33</a> | |
| 375 | + | </td> | |
| 376 | + | <td class="c-who dim">T</td> | |
| 377 | + | <td class="c-when nowrap dim"> | |
| 378 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 379 | + | </td> | |
| 380 | + | </tr> | |
| 381 | + | <tr id="c-SHA7"> | |
| 382 | + | <td class="cur" aria-hidden="true"> | |
| 383 | + | </td> | |
| 384 | + | <td class="c-sha mute">SHA7</td> | |
| 385 | + | <td class="c-subject"> | |
| 386 | + | <a href="/long/commit/SHA40">commit number 32</a> | |
| 387 | + | </td> | |
| 388 | + | <td class="c-who dim">T</td> | |
| 389 | + | <td class="c-when nowrap dim"> | |
| 390 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 391 | + | </td> | |
| 392 | + | </tr> | |
| 393 | + | </tbody> | |
| 394 | + | </table> | |
| 395 | + | </section> | |
| 396 | + | <section aria-label="2026-03-16"> | |
| 397 | + | <h2 class="rule-h"> | |
| 398 | + | <span>2026-03-16</span> | |
| 399 | + | </h2> | |
| 400 | + | <table class="list commits" aria-label="Commits"> | |
| 401 | + | <tbody data-rows> | |
| 402 | + | <tr id="c-SHA7"> | |
| 403 | + | <td class="cur" aria-hidden="true"> | |
| 404 | + | </td> | |
| 405 | + | <td class="c-sha mute">SHA7</td> | |
| 406 | + | <td class="c-subject"> | |
| 407 | + | <a href="/long/commit/SHA40">commit number 31</a> | |
| 408 | + | </td> | |
| 409 | + | <td class="c-who dim">T</td> | |
| 410 | + | <td class="c-when nowrap dim"> | |
| 411 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 412 | + | </td> | |
| 413 | + | </tr> | |
| 414 | + | <tr id="c-SHA7"> | |
| 415 | + | <td class="cur" aria-hidden="true"> | |
| 416 | + | </td> | |
| 417 | + | <td class="c-sha mute">SHA7</td> | |
| 418 | + | <td class="c-subject"> | |
| 419 | + | <a href="/long/commit/SHA40">commit number 30</a> | |
| 420 | + | </td> | |
| 421 | + | <td class="c-who dim">T</td> | |
| 422 | + | <td class="c-when nowrap dim"> | |
| 423 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 424 | + | </td> | |
| 425 | + | </tr> | |
| 426 | + | </tbody> | |
| 427 | + | </table> | |
| 428 | + | </section> | |
| 429 | + | <section aria-label="2026-03-15"> | |
| 430 | + | <h2 class="rule-h"> | |
| 431 | + | <span>2026-03-15</span> | |
| 432 | + | </h2> | |
| 433 | + | <table class="list commits" aria-label="Commits"> | |
| 434 | + | <tbody data-rows> | |
| 435 | + | <tr id="c-SHA7"> | |
| 436 | + | <td class="cur" aria-hidden="true"> | |
| 437 | + | </td> | |
| 438 | + | <td class="c-sha mute">SHA7</td> | |
| 439 | + | <td class="c-subject"> | |
| 440 | + | <a href="/long/commit/SHA40">commit number 29</a> | |
| 441 | + | </td> | |
| 442 | + | <td class="c-who dim">T</td> | |
| 443 | + | <td class="c-when nowrap dim"> | |
| 444 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 445 | + | </td> | |
| 446 | + | </tr> | |
| 447 | + | <tr id="c-SHA7"> | |
| 448 | + | <td class="cur" aria-hidden="true"> | |
| 449 | + | </td> | |
| 450 | + | <td class="c-sha mute">SHA7</td> | |
| 451 | + | <td class="c-subject"> | |
| 452 | + | <a href="/long/commit/SHA40">commit number 28</a> | |
| 453 | + | </td> | |
| 454 | + | <td class="c-who dim">T</td> | |
| 455 | + | <td class="c-when nowrap dim"> | |
| 456 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 457 | + | </td> | |
| 458 | + | </tr> | |
| 459 | + | </tbody> | |
| 460 | + | </table> | |
| 461 | + | </section> | |
| 462 | + | <section aria-label="2026-03-14"> | |
| 463 | + | <h2 class="rule-h"> | |
| 464 | + | <span>2026-03-14</span> | |
| 465 | + | </h2> | |
| 466 | + | <table class="list commits" aria-label="Commits"> | |
| 467 | + | <tbody data-rows> | |
| 468 | + | <tr id="c-SHA7"> | |
| 469 | + | <td class="cur" aria-hidden="true"> | |
| 470 | + | </td> | |
| 471 | + | <td class="c-sha mute">SHA7</td> | |
| 472 | + | <td class="c-subject"> | |
| 473 | + | <a href="/long/commit/SHA40">commit number 27</a> | |
| 474 | + | </td> | |
| 475 | + | <td class="c-who dim">T</td> | |
| 476 | + | <td class="c-when nowrap dim"> | |
| 477 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 478 | + | </td> | |
| 479 | + | </tr> | |
| 480 | + | <tr id="c-SHA7"> | |
| 481 | + | <td class="cur" aria-hidden="true"> | |
| 482 | + | </td> | |
| 483 | + | <td class="c-sha mute">SHA7</td> | |
| 484 | + | <td class="c-subject"> | |
| 485 | + | <a href="/long/commit/SHA40">commit number 26</a> | |
| 486 | + | </td> | |
| 487 | + | <td class="c-who dim">T</td> | |
| 488 | + | <td class="c-when nowrap dim"> | |
| 489 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 490 | + | </td> | |
| 491 | + | </tr> | |
| 492 | + | </tbody> | |
| 493 | + | </table> | |
| 494 | + | </section> | |
| 495 | + | <section aria-label="2026-03-13"> | |
| 496 | + | <h2 class="rule-h"> | |
| 497 | + | <span>2026-03-13</span> | |
| 498 | + | </h2> | |
| 499 | + | <table class="list commits" aria-label="Commits"> | |
| 500 | + | <tbody data-rows> | |
| 501 | + | <tr id="c-SHA7"> | |
| 502 | + | <td class="cur" aria-hidden="true"> | |
| 503 | + | </td> | |
| 504 | + | <td class="c-sha mute">SHA7</td> | |
| 505 | + | <td class="c-subject"> | |
| 506 | + | <a href="/long/commit/SHA40">commit number 25</a> | |
| 507 | + | </td> | |
| 508 | + | <td class="c-who dim">T</td> | |
| 509 | + | <td class="c-when nowrap dim"> | |
| 510 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 511 | + | </td> | |
| 512 | + | </tr> | |
| 513 | + | <tr id="c-SHA7"> | |
| 514 | + | <td class="cur" aria-hidden="true"> | |
| 515 | + | </td> | |
| 516 | + | <td class="c-sha mute">SHA7</td> | |
| 517 | + | <td class="c-subject"> | |
| 518 | + | <a href="/long/commit/SHA40">commit number 24</a> | |
| 519 | + | </td> | |
| 520 | + | <td class="c-who dim">T</td> | |
| 521 | + | <td class="c-when nowrap dim"> | |
| 522 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 523 | + | </td> | |
| 524 | + | </tr> | |
| 525 | + | </tbody> | |
| 526 | + | </table> | |
| 527 | + | </section> | |
| 528 | + | <section aria-label="2026-03-12"> | |
| 529 | + | <h2 class="rule-h"> | |
| 530 | + | <span>2026-03-12</span> | |
| 531 | + | </h2> | |
| 532 | + | <table class="list commits" aria-label="Commits"> | |
| 533 | + | <tbody data-rows> | |
| 534 | + | <tr id="c-SHA7"> | |
| 535 | + | <td class="cur" aria-hidden="true"> | |
| 536 | + | </td> | |
| 537 | + | <td class="c-sha mute">SHA7</td> | |
| 538 | + | <td class="c-subject"> | |
| 539 | + | <a href="/long/commit/SHA40">commit number 23</a> | |
| 540 | + | </td> | |
| 541 | + | <td class="c-who dim">T</td> | |
| 542 | + | <td class="c-when nowrap dim"> | |
| 543 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 544 | + | </td> | |
| 545 | + | </tr> | |
| 546 | + | <tr id="c-SHA7"> | |
| 547 | + | <td class="cur" aria-hidden="true"> | |
| 548 | + | </td> | |
| 549 | + | <td class="c-sha mute">SHA7</td> | |
| 550 | + | <td class="c-subject"> | |
| 551 | + | <a href="/long/commit/SHA40">commit number 22</a> | |
| 552 | + | </td> | |
| 553 | + | <td class="c-who dim">T</td> | |
| 554 | + | <td class="c-when nowrap dim"> | |
| 555 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 556 | + | </td> | |
| 557 | + | </tr> | |
| 558 | + | </tbody> | |
| 559 | + | </table> | |
| 560 | + | </section> | |
| 561 | + | <section aria-label="2026-03-11"> | |
| 562 | + | <h2 class="rule-h"> | |
| 563 | + | <span>2026-03-11</span> | |
| 564 | + | </h2> | |
| 565 | + | <table class="list commits" aria-label="Commits"> | |
| 566 | + | <tbody data-rows> | |
| 567 | + | <tr id="c-SHA7"> | |
| 568 | + | <td class="cur" aria-hidden="true"> | |
| 569 | + | </td> | |
| 570 | + | <td class="c-sha mute">SHA7</td> | |
| 571 | + | <td class="c-subject"> | |
| 572 | + | <a href="/long/commit/SHA40">commit number 21</a> | |
| 573 | + | </td> | |
| 574 | + | <td class="c-who dim">T</td> | |
| 575 | + | <td class="c-when nowrap dim"> | |
| 576 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 577 | + | </td> | |
| 578 | + | </tr> | |
| 579 | + | <tr id="c-SHA7"> | |
| 580 | + | <td class="cur" aria-hidden="true"> | |
| 581 | + | </td> | |
| 582 | + | <td class="c-sha mute">SHA7</td> | |
| 583 | + | <td class="c-subject"> | |
| 584 | + | <a href="/long/commit/SHA40">commit number 20</a> | |
| 585 | + | </td> | |
| 586 | + | <td class="c-who dim">T</td> | |
| 587 | + | <td class="c-when nowrap dim"> | |
| 588 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 589 | + | </td> | |
| 590 | + | </tr> | |
| 591 | + | </tbody> | |
| 592 | + | </table> | |
| 593 | + | </section> | |
| 594 | + | <section aria-label="2026-03-10"> | |
| 595 | + | <h2 class="rule-h"> | |
| 596 | + | <span>2026-03-10</span> | |
| 597 | + | </h2> | |
| 598 | + | <table class="list commits" aria-label="Commits"> | |
| 599 | + | <tbody data-rows> | |
| 600 | + | <tr id="c-SHA7"> | |
| 601 | + | <td class="cur" aria-hidden="true"> | |
| 602 | + | </td> | |
| 603 | + | <td class="c-sha mute">SHA7</td> | |
| 604 | + | <td class="c-subject"> | |
| 605 | + | <a href="/long/commit/SHA40">commit number 19</a> | |
| 606 | + | </td> | |
| 607 | + | <td class="c-who dim">T</td> | |
| 608 | + | <td class="c-when nowrap dim"> | |
| 609 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 610 | + | </td> | |
| 611 | + | </tr> | |
| 612 | + | <tr id="c-SHA7"> | |
| 613 | + | <td class="cur" aria-hidden="true"> | |
| 614 | + | </td> | |
| 615 | + | <td class="c-sha mute">SHA7</td> | |
| 616 | + | <td class="c-subject"> | |
| 617 | + | <a href="/long/commit/SHA40">commit number 18</a> | |
| 618 | + | </td> | |
| 619 | + | <td class="c-who dim">T</td> | |
| 620 | + | <td class="c-when nowrap dim"> | |
| 621 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 622 | + | </td> | |
| 623 | + | </tr> | |
| 624 | + | </tbody> | |
| 625 | + | </table> | |
| 626 | + | </section> | |
| 627 | + | <section aria-label="2026-03-09"> | |
| 628 | + | <h2 class="rule-h"> | |
| 629 | + | <span>2026-03-09</span> | |
| 630 | + | </h2> | |
| 631 | + | <table class="list commits" aria-label="Commits"> | |
| 632 | + | <tbody data-rows> | |
| 633 | + | <tr id="c-SHA7"> | |
| 634 | + | <td class="cur" aria-hidden="true"> | |
| 635 | + | </td> | |
| 636 | + | <td class="c-sha mute">SHA7</td> | |
| 637 | + | <td class="c-subject"> | |
| 638 | + | <a href="/long/commit/SHA40">commit number 17</a> | |
| 639 | + | </td> | |
| 640 | + | <td class="c-who dim">T</td> | |
| 641 | + | <td class="c-when nowrap dim"> | |
| 642 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 643 | + | </td> | |
| 644 | + | </tr> | |
| 645 | + | <tr id="c-SHA7"> | |
| 646 | + | <td class="cur" aria-hidden="true"> | |
| 647 | + | </td> | |
| 648 | + | <td class="c-sha mute">SHA7</td> | |
| 649 | + | <td class="c-subject"> | |
| 650 | + | <a href="/long/commit/SHA40">commit number 16</a> | |
| 651 | + | </td> | |
| 652 | + | <td class="c-who dim">T</td> | |
| 653 | + | <td class="c-when nowrap dim"> | |
| 654 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 655 | + | </td> | |
| 656 | + | </tr> | |
| 657 | + | </tbody> | |
| 658 | + | </table> | |
| 659 | + | </section> | |
| 660 | + | <section aria-label="2026-03-08"> | |
| 661 | + | <h2 class="rule-h"> | |
| 662 | + | <span>2026-03-08</span> | |
| 663 | + | </h2> | |
| 664 | + | <table class="list commits" aria-label="Commits"> | |
| 665 | + | <tbody data-rows> | |
| 666 | + | <tr id="c-SHA7"> | |
| 667 | + | <td class="cur" aria-hidden="true"> | |
| 668 | + | </td> | |
| 669 | + | <td class="c-sha mute">SHA7</td> | |
| 670 | + | <td class="c-subject"> | |
| 671 | + | <a href="/long/commit/SHA40">commit number 15</a> | |
| 672 | + | </td> | |
| 673 | + | <td class="c-who dim">T</td> | |
| 674 | + | <td class="c-when nowrap dim"> | |
| 675 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 676 | + | </td> | |
| 677 | + | </tr> | |
| 678 | + | <tr id="c-SHA7"> | |
| 679 | + | <td class="cur" aria-hidden="true"> | |
| 680 | + | </td> | |
| 681 | + | <td class="c-sha mute">SHA7</td> | |
| 682 | + | <td class="c-subject"> | |
| 683 | + | <a href="/long/commit/SHA40">commit number 14</a> | |
| 684 | + | </td> | |
| 685 | + | <td class="c-who dim">T</td> | |
| 686 | + | <td class="c-when nowrap dim"> | |
| 687 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 688 | + | </td> | |
| 689 | + | </tr> | |
| 690 | + | </tbody> | |
| 691 | + | </table> | |
| 692 | + | </section> | |
| 693 | + | <section aria-label="2026-03-07"> | |
| 694 | + | <h2 class="rule-h"> | |
| 695 | + | <span>2026-03-07</span> | |
| 696 | + | </h2> | |
| 697 | + | <table class="list commits" aria-label="Commits"> | |
| 698 | + | <tbody data-rows> | |
| 699 | + | <tr id="c-SHA7"> | |
| 700 | + | <td class="cur" aria-hidden="true"> | |
| 701 | + | </td> | |
| 702 | + | <td class="c-sha mute">SHA7</td> | |
| 703 | + | <td class="c-subject"> | |
| 704 | + | <a href="/long/commit/SHA40">commit number 13</a> | |
| 705 | + | </td> | |
| 706 | + | <td class="c-who dim">T</td> | |
| 707 | + | <td class="c-when nowrap dim"> | |
| 708 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 709 | + | </td> | |
| 710 | + | </tr> | |
| 711 | + | <tr id="c-SHA7"> | |
| 712 | + | <td class="cur" aria-hidden="true"> | |
| 713 | + | </td> | |
| 714 | + | <td class="c-sha mute">SHA7</td> | |
| 715 | + | <td class="c-subject"> | |
| 716 | + | <a href="/long/commit/SHA40">commit number 12</a> | |
| 717 | + | </td> | |
| 718 | + | <td class="c-who dim">T</td> | |
| 719 | + | <td class="c-when nowrap dim"> | |
| 720 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 721 | + | </td> | |
| 722 | + | </tr> | |
| 723 | + | </tbody> | |
| 724 | + | </table> | |
| 725 | + | </section> | |
| 726 | + | <section aria-label="2026-03-06"> | |
| 727 | + | <h2 class="rule-h"> | |
| 728 | + | <span>2026-03-06</span> | |
| 729 | + | </h2> | |
| 730 | + | <table class="list commits" aria-label="Commits"> | |
| 731 | + | <tbody data-rows> | |
| 732 | + | <tr id="c-SHA7"> | |
| 733 | + | <td class="cur" aria-hidden="true"> | |
| 734 | + | </td> | |
| 735 | + | <td class="c-sha mute">SHA7</td> | |
| 736 | + | <td class="c-subject"> | |
| 737 | + | <a href="/long/commit/SHA40">commit number 11</a> | |
| 738 | + | </td> | |
| 739 | + | <td class="c-who dim">T</td> | |
| 740 | + | <td class="c-when nowrap dim"> | |
| 741 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 742 | + | </td> | |
| 743 | + | </tr> | |
| 744 | + | <tr id="c-SHA7"> | |
| 745 | + | <td class="cur" aria-hidden="true"> | |
| 746 | + | </td> | |
| 747 | + | <td class="c-sha mute">SHA7</td> | |
| 748 | + | <td class="c-subject"> | |
| 749 | + | <a href="/long/commit/SHA40">commit number 10</a> | |
| 750 | + | </td> | |
| 751 | + | <td class="c-who dim">T</td> | |
| 752 | + | <td class="c-when nowrap dim"> | |
| 753 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 754 | + | </td> | |
| 755 | + | </tr> | |
| 756 | + | </tbody> | |
| 757 | + | </table> | |
| 758 | + | </section> | |
| 759 | + | <section aria-label="2026-03-05"> | |
| 760 | + | <h2 class="rule-h"> | |
| 761 | + | <span>2026-03-05</span> | |
| 762 | + | </h2> | |
| 763 | + | <table class="list commits" aria-label="Commits"> | |
| 764 | + | <tbody data-rows> | |
| 765 | + | <tr id="c-SHA7"> | |
| 766 | + | <td class="cur" aria-hidden="true"> | |
| 767 | + | </td> | |
| 768 | + | <td class="c-sha mute">SHA7</td> | |
| 769 | + | <td class="c-subject"> | |
| 770 | + | <a href="/long/commit/SHA40">commit number 9</a> | |
| 771 | + | </td> | |
| 772 | + | <td class="c-who dim">T</td> | |
| 773 | + | <td class="c-when nowrap dim"> | |
| 774 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 775 | + | </td> | |
| 776 | + | </tr> | |
| 777 | + | <tr id="c-SHA7"> | |
| 778 | + | <td class="cur" aria-hidden="true"> | |
| 779 | + | </td> | |
| 780 | + | <td class="c-sha mute">SHA7</td> | |
| 781 | + | <td class="c-subject"> | |
| 782 | + | <a href="/long/commit/SHA40">commit number 8</a> | |
| 783 | + | </td> | |
| 784 | + | <td class="c-who dim">T</td> | |
| 785 | + | <td class="c-when nowrap dim"> | |
| 786 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 787 | + | </td> | |
| 788 | + | </tr> | |
| 789 | + | </tbody> | |
| 790 | + | </table> | |
| 791 | + | </section> | |
| 792 | + | <section aria-label="2026-03-04"> | |
| 793 | + | <h2 class="rule-h"> | |
| 794 | + | <span>2026-03-04</span> | |
| 795 | + | </h2> | |
| 796 | + | <table class="list commits" aria-label="Commits"> | |
| 797 | + | <tbody data-rows> | |
| 798 | + | <tr id="c-SHA7"> | |
| 799 | + | <td class="cur" aria-hidden="true"> | |
| 800 | + | </td> | |
| 801 | + | <td class="c-sha mute">SHA7</td> | |
| 802 | + | <td class="c-subject"> | |
| 803 | + | <a href="/long/commit/SHA40">commit number 7</a> | |
| 804 | + | </td> | |
| 805 | + | <td class="c-who dim">T</td> | |
| 806 | + | <td class="c-when nowrap dim"> | |
| 807 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 808 | + | </td> | |
| 809 | + | </tr> | |
| 810 | + | <tr id="c-SHA7"> | |
| 811 | + | <td class="cur" aria-hidden="true"> | |
| 812 | + | </td> | |
| 813 | + | <td class="c-sha mute">SHA7</td> | |
| 814 | + | <td class="c-subject"> | |
| 815 | + | <a href="/long/commit/SHA40">commit number 6</a> | |
| 816 | + | </td> | |
| 817 | + | <td class="c-who dim">T</td> | |
| 818 | + | <td class="c-when nowrap dim"> | |
| 819 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 820 | + | </td> | |
| 821 | + | </tr> | |
| 822 | + | </tbody> | |
| 823 | + | </table> | |
| 824 | + | </section> | |
| 825 | + | <section aria-label="2026-03-03"> | |
| 826 | + | <h2 class="rule-h"> | |
| 827 | + | <span>2026-03-03</span> | |
| 828 | + | </h2> | |
| 829 | + | <table class="list commits" aria-label="Commits"> | |
| 830 | + | <tbody data-rows> | |
| 831 | + | <tr id="c-SHA7"> | |
| 832 | + | <td class="cur" aria-hidden="true"> | |
| 833 | + | </td> | |
| 834 | + | <td class="c-sha mute">SHA7</td> | |
| 835 | + | <td class="c-subject"> | |
| 836 | + | <a href="/long/commit/SHA40">commit number 5</a> | |
| 837 | + | </td> | |
| 838 | + | <td class="c-who dim">T</td> | |
| 839 | + | <td class="c-when nowrap dim"> | |
| 840 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 841 | + | </td> | |
| 842 | + | </tr> | |
| 843 | + | <tr id="c-SHA7"> | |
| 844 | + | <td class="cur" aria-hidden="true"> | |
| 845 | + | </td> | |
| 846 | + | <td class="c-sha mute">SHA7</td> | |
| 847 | + | <td class="c-subject"> | |
| 848 | + | <a href="/long/commit/SHA40">commit number 4</a> | |
| 849 | + | </td> | |
| 850 | + | <td class="c-who dim">T</td> | |
| 851 | + | <td class="c-when nowrap dim"> | |
| 852 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 853 | + | </td> | |
| 854 | + | </tr> | |
| 855 | + | </tbody> | |
| 856 | + | </table> | |
| 857 | + | </section> | |
| 858 | + | <section aria-label="2026-03-02"> | |
| 859 | + | <h2 class="rule-h"> | |
| 860 | + | <span>2026-03-02</span> | |
| 861 | + | </h2> | |
| 862 | + | <table class="list commits" aria-label="Commits"> | |
| 863 | + | <tbody data-rows> | |
| 864 | + | <tr id="c-SHA7"> | |
| 865 | + | <td class="cur" aria-hidden="true"> | |
| 866 | + | </td> | |
| 867 | + | <td class="c-sha mute">SHA7</td> | |
| 868 | + | <td class="c-subject"> | |
| 869 | + | <a href="/long/commit/SHA40">commit number 3</a> | |
| 870 | + | </td> | |
| 871 | + | <td class="c-who dim">T</td> | |
| 872 | + | <td class="c-when nowrap dim"> | |
| 873 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 874 | + | </td> | |
| 875 | + | </tr> | |
| 876 | + | <tr id="c-SHA7"> | |
| 877 | + | <td class="cur" aria-hidden="true"> | |
| 878 | + | </td> | |
| 879 | + | <td class="c-sha mute">SHA7</td> | |
| 880 | + | <td class="c-subject"> | |
| 881 | + | <a href="/long/commit/SHA40">commit number 2</a> | |
| 882 | + | </td> | |
| 883 | + | <td class="c-who dim">T</td> | |
| 884 | + | <td class="c-when nowrap dim"> | |
| 885 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 886 | + | </td> | |
| 887 | + | </tr> | |
| 888 | + | </tbody> | |
| 889 | + | </table> | |
| 890 | + | </section> | |
| 891 | + | </div> | |
| 892 | + | <nav class="pager" aria-label="Pages"> | |
| 893 | + | <span class="mute">← newest</span> | |
| 894 | + | <a href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">older →</a> | |
| 895 | + | </nav> | |
| 896 | + | </main> | |
| 897 | + | <aside class="rail" aria-label="Context"> | |
| 898 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 899 | + | <summary>history · refs on this page</summary> | |
| 900 | + | <section aria-labelledby="r-hist"> | |
| 901 | + | <h2 id="r-hist">history</h2> | |
| 902 | + | <dl class="kv"> | |
| 903 | + | <dt>ref</dt> | |
| 904 | + | <dd>main</dd> | |
| 905 | + | <dt>on page</dt> | |
| 906 | + | <dd>50</dd> | |
| 907 | + | </dl> | |
| 908 | + | </section> | |
| 909 | + | <section aria-labelledby="r-refs"> | |
| 910 | + | <h2 id="r-refs">refs on this page</h2> | |
| 911 | + | <nav class="outline" aria-label="Refs on this page"> | |
| 912 | + | <a href="#c-SHA7">branch main</a> | |
| 913 | + | </nav> | |
| 914 | + | </section> | |
| 915 | + | </details> | |
| 916 | + | </aside> | |
| 917 | + | </div> | |
| 918 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 919 | + | <div class="panel-head"> | |
| 920 | + | <span>keys</span> | |
| 921 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 922 | + | </div> | |
| 923 | + | <dl class="keys"> | |
| 924 | + | <div> | |
| 925 | + | <dt> | |
| 926 | + | <kbd>j</kbd> | |
| 927 | + | <kbd>k</kbd> | |
| 928 | + | </dt> | |
| 929 | + | <dd>move cursor down / up</dd> | |
| 930 | + | </div> | |
| 931 | + | <div> | |
| 932 | + | <dt> | |
| 933 | + | <kbd>enter</kbd> | |
| 934 | + | </dt> | |
| 935 | + | <dd>open row under cursor</dd> | |
| 936 | + | </div> | |
| 937 | + | <div> | |
| 938 | + | <dt> | |
| 939 | + | <kbd>g</kbd> | |
| 940 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 941 | + | </dt> | |
| 942 | + | <dd>first / last row</dd> | |
| 943 | + | </div> | |
| 944 | + | <div> | |
| 945 | + | <dt> | |
| 946 | + | <kbd>/</kbd> | |
| 947 | + | </dt> | |
| 948 | + | <dd>focus the filter</dd> | |
| 949 | + | </div> | |
| 950 | + | <div> | |
| 951 | + | <dt> | |
| 952 | + | <kbd>g h</kbd> | |
| 953 | + | <kbd>g t</kbd> | |
| 954 | + | </dt> | |
| 955 | + | <dd>go home / tree</dd> | |
| 956 | + | </div> | |
| 957 | + | <div> | |
| 958 | + | <dt> | |
| 959 | + | <kbd>g c</kbd> | |
| 960 | + | <kbd>g r</kbd> | |
| 961 | + | </dt> | |
| 962 | + | <dd>go commits / refs</dd> | |
| 963 | + | </div> | |
| 964 | + | <div> | |
| 965 | + | <dt> | |
| 966 | + | <kbd>b</kbd> | |
| 967 | + | </dt> | |
| 968 | + | <dd>switch ref (branches, tags)</dd> | |
| 969 | + | </div> | |
| 970 | + | <div> | |
| 971 | + | <dt> | |
| 972 | + | <kbd>:</kbd> | |
| 973 | + | <kbd>f</kbd> | |
| 974 | + | </dt> | |
| 975 | + | <dd>go to: section, ref, file, repo</dd> | |
| 976 | + | </div> | |
| 977 | + | <div> | |
| 978 | + | <dt> | |
| 979 | + | <kbd>y</kbd> | |
| 980 | + | </dt> | |
| 981 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 982 | + | </div> | |
| 983 | + | <div> | |
| 984 | + | <dt> | |
| 985 | + | <kbd>s</kbd> | |
| 986 | + | <kbd>t</kbd> | |
| 987 | + | <kbd>c</kbd> | |
| 988 | + | <kbd>r</kbd> | |
| 989 | + | </dt> | |
| 990 | + | <dd>repo sections (when shown)</dd> | |
| 991 | + | </div> | |
| 992 | + | <div> | |
| 993 | + | <dt> | |
| 994 | + | <kbd>d</kbd> | |
| 995 | + | <kbd>u</kbd> | |
| 996 | + | <kbd>x</kbd> | |
| 997 | + | </dt> | |
| 998 | + | <dd>commit: split / unified / collapse all</dd> | |
| 999 | + | </div> | |
| 1000 | + | <div> | |
| 1001 | + | <dt> | |
| 1002 | + | <kbd>esc</kbd> | |
| 1003 | + | </dt> | |
| 1004 | + | <dd>close panel</dd> | |
| 1005 | + | </div> | |
| 1006 | + | </dl> | |
| 1007 | + | </dialog> | |
| 1008 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 1009 | + | <div class="panel-head"> | |
| 1010 | + | <label for="refs-filter">ref></label> | |
| 1011 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 1012 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 1013 | + | </div> | |
| 1014 | + | <div class="panel-cols" data-picklist> | |
| 1015 | + | <section aria-label="Branches"> | |
| 1016 | + | <h2>branches <span class="mute">1</span> | |
| 1017 | + | </h2> | |
| 1018 | + | <ul> | |
| 1019 | + | <li> | |
| 1020 | + | <a href="/long/tree/main" aria-current="true"> | |
| 1021 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 1022 | + | </li> | |
| 1023 | + | </ul> | |
| 1024 | + | </section> | |
| 1025 | + | <section aria-label="Tags"> | |
| 1026 | + | <h2>tags <span class="mute">0</span> | |
| 1027 | + | </h2> | |
| 1028 | + | <ul> | |
| 1029 | + | </ul> | |
| 1030 | + | </section> | |
| 1031 | + | </div> | |
| 1032 | + | </dialog> | |
| 1033 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 1034 | + | <div class="panel-head"> | |
| 1035 | + | <label for="cmd-q" class="hi">:</label> | |
| 1036 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 1037 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 1038 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 1039 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 1040 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 1041 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 1042 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 1043 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 1044 | + | </div> | |
| 1045 | + | <ul id="cmd-list" class="cmd-list"> | |
| 1046 | + | <li data-k="go home" data-cursor> | |
| 1047 | + | <a href="/" tabindex="-1"> | |
| 1048 | + | <span class="k">go</span>home</a> | |
| 1049 | + | </li> | |
| 1050 | + | <li data-k="section summary"> | |
| 1051 | + | <a href="/long" tabindex="-1"> | |
| 1052 | + | <span class="k">section</span>summary</a> | |
| 1053 | + | </li> | |
| 1054 | + | <li data-k="section tree"> | |
| 1055 | + | <a href="/long/tree/main" tabindex="-1"> | |
| 1056 | + | <span class="k">section</span>tree</a> | |
| 1057 | + | </li> | |
| 1058 | + | <li data-k="section commits"> | |
| 1059 | + | <a href="/long/commits/main" tabindex="-1"> | |
| 1060 | + | <span class="k">section</span>commits</a> | |
| 1061 | + | </li> | |
| 1062 | + | <li data-k="section refs"> | |
| 1063 | + | <a href="/long/refs" tabindex="-1"> | |
| 1064 | + | <span class="k">section</span>refs</a> | |
| 1065 | + | </li> | |
| 1066 | + | <li data-k="branch main"> | |
| 1067 | + | <a href="/long/tree/main" tabindex="-1"> | |
| 1068 | + | <span class="k">branch</span>main</a> | |
| 1069 | + | </li> | |
| 1070 | + | <li data-k="repo diffs"> | |
| 1071 | + | <a href="/diffs" tabindex="-1"> | |
| 1072 | + | <span class="k">repo</span>diffs</a> | |
| 1073 | + | </li> | |
| 1074 | + | </ul> | |
| 1075 | + | </dialog> | |
| 1076 | + | <footer class="bar status" aria-label="Status"> | |
| 1077 | + | <span class="mode">commits</span> | |
| 1078 | + | <span class="st-ctx">main · 50 commits · more</span> | |
| 1079 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 1080 | + | <span class="hi">:</span> go to…</button> | |
| 1081 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 1082 | + | </span> | |
| 1083 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |
| 1084 | + | <span class="st-src"> | |
| 1085 | + | <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> | |
| 1087 | + | </span> | |
| 1088 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">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> | |
| 1090 | + | </footer> | |
| 1091 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 1092 | + | if (q('dialog[open]').count) return; | |
| 1093 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 1094 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 1095 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 1096 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 1097 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 1098 | + | })(1); | |
| 1099 | + | k-k from:body -> ((d) => { | |
| 1100 | + | if (q('dialog[open]').count) return; | |
| 1101 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 1102 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 1103 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 1104 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 1105 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 1106 | + | })(-1); | |
| 1107 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 1108 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 1109 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 1110 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 1111 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 1112 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 1113 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 1114 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 1115 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 1116 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 1117 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 1118 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 1119 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 1120 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 1121 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 1122 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 1123 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 1124 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 1125 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 1126 | + | k-permalink from:body -> { | |
| 1127 | + | let b = document.body.dataset; | |
| 1128 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 1129 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 1130 | + | navigator.clipboard?.writeText(location.href); | |
| 1131 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 1132 | + | }"> | |
| 1133 | + | </div> | |
| 1134 | + | </body> | |
| 1135 | + | </html> | |
| @@ -0,0 +1,1135 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>commits · long · example.test</title> | |||
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 10 | + | </script> | |||
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 12 | + | </script> | |||
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 14 | + | </script> | |||
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 16 | + | </script> | |||
| 17 | + | </head> | |||
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |||
| 19 | + | <a class="skip" href="#main">skip to content</a> | |||
| 20 | + | <header class="bar top"> | |||
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |||
| 22 | + | <span class="fade" aria-hidden="true"> | |||
| 23 | + | </span> | |||
| 24 | + | <nav class="crumbs" aria-label="Path"> | |||
| 25 | + | <ol> | |||
| 26 | + | <li> | |||
| 27 | + | <a href="/long">long</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/long/commits/main">commits</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/long/commits/main" aria-current="page">main</a> | |||
| 34 | + | </li> | |||
| 35 | + | </ol> | |||
| 36 | + | </nav> | |||
| 37 | + | <nav class="secs" aria-label="Sections"> | |||
| 38 | + | <ul> | |||
| 39 | + | <li> | |||
| 40 | + | <a href="/long" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 41 | + | </li> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/long/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/long/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/long/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |||
| 50 | + | </li> | |||
| 51 | + | </ul> | |||
| 52 | + | </nav> | |||
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |||
| 54 | + | </button> | |||
| 55 | + | </header> | |||
| 56 | + | <div class="work has-rail"> | |||
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 58 | + | <div class="filter"> | |||
| 59 | + | <label for="commit-filter">filter></label> | |||
| 60 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |||
| 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')"> | |||
| 63 | + | </div> | |||
| 64 | + | <div class="days" id="commits-head"> | |||
| 65 | + | <section aria-label="2026-03-26"> | |||
| 66 | + | <h2 class="rule-h"> | |||
| 67 | + | <span>2026-03-26</span> | |||
| 68 | + | </h2> | |||
| 69 | + | <table class="list commits" aria-label="Commits"> | |||
| 70 | + | <tbody data-rows> | |||
| 71 | + | <tr id="c-SHA7" data-cursor> | |||
| 72 | + | <td class="cur" aria-hidden="true"> | |||
| 73 | + | </td> | |||
| 74 | + | <td class="c-sha mute">SHA7</td> | |||
| 75 | + | <td class="c-subject"> | |||
| 76 | + | <a href="/long/commit/SHA40">commit number 51</a> | |||
| 77 | + | <span class="tagged">[main]</span> | |||
| 78 | + | </td> | |||
| 79 | + | <td class="c-who dim">T</td> | |||
| 80 | + | <td class="c-when nowrap dim"> | |||
| 81 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 82 | + | </td> | |||
| 83 | + | </tr> | |||
| 84 | + | <tr id="c-SHA7"> | |||
| 85 | + | <td class="cur" aria-hidden="true"> | |||
| 86 | + | </td> | |||
| 87 | + | <td class="c-sha mute">SHA7</td> | |||
| 88 | + | <td class="c-subject"> | |||
| 89 | + | <a href="/long/commit/SHA40">commit number 50</a> | |||
| 90 | + | </td> | |||
| 91 | + | <td class="c-who dim">T</td> | |||
| 92 | + | <td class="c-when nowrap dim"> | |||
| 93 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 94 | + | </td> | |||
| 95 | + | </tr> | |||
| 96 | + | </tbody> | |||
| 97 | + | </table> | |||
| 98 | + | </section> | |||
| 99 | + | <section aria-label="2026-03-25"> | |||
| 100 | + | <h2 class="rule-h"> | |||
| 101 | + | <span>2026-03-25</span> | |||
| 102 | + | </h2> | |||
| 103 | + | <table class="list commits" aria-label="Commits"> | |||
| 104 | + | <tbody data-rows> | |||
| 105 | + | <tr id="c-SHA7"> | |||
| 106 | + | <td class="cur" aria-hidden="true"> | |||
| 107 | + | </td> | |||
| 108 | + | <td class="c-sha mute">SHA7</td> | |||
| 109 | + | <td class="c-subject"> | |||
| 110 | + | <a href="/long/commit/SHA40">commit number 49</a> | |||
| 111 | + | </td> | |||
| 112 | + | <td class="c-who dim">T</td> | |||
| 113 | + | <td class="c-when nowrap dim"> | |||
| 114 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 115 | + | </td> | |||
| 116 | + | </tr> | |||
| 117 | + | <tr id="c-SHA7"> | |||
| 118 | + | <td class="cur" aria-hidden="true"> | |||
| 119 | + | </td> | |||
| 120 | + | <td class="c-sha mute">SHA7</td> | |||
| 121 | + | <td class="c-subject"> | |||
| 122 | + | <a href="/long/commit/SHA40">commit number 48</a> | |||
| 123 | + | </td> | |||
| 124 | + | <td class="c-who dim">T</td> | |||
| 125 | + | <td class="c-when nowrap dim"> | |||
| 126 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 127 | + | </td> | |||
| 128 | + | </tr> | |||
| 129 | + | </tbody> | |||
| 130 | + | </table> | |||
| 131 | + | </section> | |||
| 132 | + | <section aria-label="2026-03-24"> | |||
| 133 | + | <h2 class="rule-h"> | |||
| 134 | + | <span>2026-03-24</span> | |||
| 135 | + | </h2> | |||
| 136 | + | <table class="list commits" aria-label="Commits"> | |||
| 137 | + | <tbody data-rows> | |||
| 138 | + | <tr id="c-SHA7"> | |||
| 139 | + | <td class="cur" aria-hidden="true"> | |||
| 140 | + | </td> | |||
| 141 | + | <td class="c-sha mute">SHA7</td> | |||
| 142 | + | <td class="c-subject"> | |||
| 143 | + | <a href="/long/commit/SHA40">commit number 47</a> | |||
| 144 | + | </td> | |||
| 145 | + | <td class="c-who dim">T</td> | |||
| 146 | + | <td class="c-when nowrap dim"> | |||
| 147 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 148 | + | </td> | |||
| 149 | + | </tr> | |||
| 150 | + | <tr id="c-SHA7"> | |||
| 151 | + | <td class="cur" aria-hidden="true"> | |||
| 152 | + | </td> | |||
| 153 | + | <td class="c-sha mute">SHA7</td> | |||
| 154 | + | <td class="c-subject"> | |||
| 155 | + | <a href="/long/commit/SHA40">commit number 46</a> | |||
| 156 | + | </td> | |||
| 157 | + | <td class="c-who dim">T</td> | |||
| 158 | + | <td class="c-when nowrap dim"> | |||
| 159 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 160 | + | </td> | |||
| 161 | + | </tr> | |||
| 162 | + | </tbody> | |||
| 163 | + | </table> | |||
| 164 | + | </section> | |||
| 165 | + | <section aria-label="2026-03-23"> | |||
| 166 | + | <h2 class="rule-h"> | |||
| 167 | + | <span>2026-03-23</span> | |||
| 168 | + | </h2> | |||
| 169 | + | <table class="list commits" aria-label="Commits"> | |||
| 170 | + | <tbody data-rows> | |||
| 171 | + | <tr id="c-SHA7"> | |||
| 172 | + | <td class="cur" aria-hidden="true"> | |||
| 173 | + | </td> | |||
| 174 | + | <td class="c-sha mute">SHA7</td> | |||
| 175 | + | <td class="c-subject"> | |||
| 176 | + | <a href="/long/commit/SHA40">commit number 45</a> | |||
| 177 | + | </td> | |||
| 178 | + | <td class="c-who dim">T</td> | |||
| 179 | + | <td class="c-when nowrap dim"> | |||
| 180 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 181 | + | </td> | |||
| 182 | + | </tr> | |||
| 183 | + | <tr id="c-SHA7"> | |||
| 184 | + | <td class="cur" aria-hidden="true"> | |||
| 185 | + | </td> | |||
| 186 | + | <td class="c-sha mute">SHA7</td> | |||
| 187 | + | <td class="c-subject"> | |||
| 188 | + | <a href="/long/commit/SHA40">commit number 44</a> | |||
| 189 | + | </td> | |||
| 190 | + | <td class="c-who dim">T</td> | |||
| 191 | + | <td class="c-when nowrap dim"> | |||
| 192 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 193 | + | </td> | |||
| 194 | + | </tr> | |||
| 195 | + | </tbody> | |||
| 196 | + | </table> | |||
| 197 | + | </section> | |||
| 198 | + | <section aria-label="2026-03-22"> | |||
| 199 | + | <h2 class="rule-h"> | |||
| 200 | + | <span>2026-03-22</span> | |||
| 201 | + | </h2> | |||
| 202 | + | <table class="list commits" aria-label="Commits"> | |||
| 203 | + | <tbody data-rows> | |||
| 204 | + | <tr id="c-SHA7"> | |||
| 205 | + | <td class="cur" aria-hidden="true"> | |||
| 206 | + | </td> | |||
| 207 | + | <td class="c-sha mute">SHA7</td> | |||
| 208 | + | <td class="c-subject"> | |||
| 209 | + | <a href="/long/commit/SHA40">commit number 43</a> | |||
| 210 | + | </td> | |||
| 211 | + | <td class="c-who dim">T</td> | |||
| 212 | + | <td class="c-when nowrap dim"> | |||
| 213 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 214 | + | </td> | |||
| 215 | + | </tr> | |||
| 216 | + | <tr id="c-SHA7"> | |||
| 217 | + | <td class="cur" aria-hidden="true"> | |||
| 218 | + | </td> | |||
| 219 | + | <td class="c-sha mute">SHA7</td> | |||
| 220 | + | <td class="c-subject"> | |||
| 221 | + | <a href="/long/commit/SHA40">commit number 42</a> | |||
| 222 | + | </td> | |||
| 223 | + | <td class="c-who dim">T</td> | |||
| 224 | + | <td class="c-when nowrap dim"> | |||
| 225 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 226 | + | </td> | |||
| 227 | + | </tr> | |||
| 228 | + | </tbody> | |||
| 229 | + | </table> | |||
| 230 | + | </section> | |||
| 231 | + | <section aria-label="2026-03-21"> | |||
| 232 | + | <h2 class="rule-h"> | |||
| 233 | + | <span>2026-03-21</span> | |||
| 234 | + | </h2> | |||
| 235 | + | <table class="list commits" aria-label="Commits"> | |||
| 236 | + | <tbody data-rows> | |||
| 237 | + | <tr id="c-SHA7"> | |||
| 238 | + | <td class="cur" aria-hidden="true"> | |||
| 239 | + | </td> | |||
| 240 | + | <td class="c-sha mute">SHA7</td> | |||
| 241 | + | <td class="c-subject"> | |||
| 242 | + | <a href="/long/commit/SHA40">commit number 41</a> | |||
| 243 | + | </td> | |||
| 244 | + | <td class="c-who dim">T</td> | |||
| 245 | + | <td class="c-when nowrap dim"> | |||
| 246 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 247 | + | </td> | |||
| 248 | + | </tr> | |||
| 249 | + | <tr id="c-SHA7"> | |||
| 250 | + | <td class="cur" aria-hidden="true"> | |||
| 251 | + | </td> | |||
| 252 | + | <td class="c-sha mute">SHA7</td> | |||
| 253 | + | <td class="c-subject"> | |||
| 254 | + | <a href="/long/commit/SHA40">commit number 40</a> | |||
| 255 | + | </td> | |||
| 256 | + | <td class="c-who dim">T</td> | |||
| 257 | + | <td class="c-when nowrap dim"> | |||
| 258 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 259 | + | </td> | |||
| 260 | + | </tr> | |||
| 261 | + | </tbody> | |||
| 262 | + | </table> | |||
| 263 | + | </section> | |||
| 264 | + | <section aria-label="2026-03-20"> | |||
| 265 | + | <h2 class="rule-h"> | |||
| 266 | + | <span>2026-03-20</span> | |||
| 267 | + | </h2> | |||
| 268 | + | <table class="list commits" aria-label="Commits"> | |||
| 269 | + | <tbody data-rows> | |||
| 270 | + | <tr id="c-SHA7"> | |||
| 271 | + | <td class="cur" aria-hidden="true"> | |||
| 272 | + | </td> | |||
| 273 | + | <td class="c-sha mute">SHA7</td> | |||
| 274 | + | <td class="c-subject"> | |||
| 275 | + | <a href="/long/commit/SHA40">commit number 39</a> | |||
| 276 | + | </td> | |||
| 277 | + | <td class="c-who dim">T</td> | |||
| 278 | + | <td class="c-when nowrap dim"> | |||
| 279 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 280 | + | </td> | |||
| 281 | + | </tr> | |||
| 282 | + | <tr id="c-SHA7"> | |||
| 283 | + | <td class="cur" aria-hidden="true"> | |||
| 284 | + | </td> | |||
| 285 | + | <td class="c-sha mute">SHA7</td> | |||
| 286 | + | <td class="c-subject"> | |||
| 287 | + | <a href="/long/commit/SHA40">commit number 38</a> | |||
| 288 | + | </td> | |||
| 289 | + | <td class="c-who dim">T</td> | |||
| 290 | + | <td class="c-when nowrap dim"> | |||
| 291 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 292 | + | </td> | |||
| 293 | + | </tr> | |||
| 294 | + | </tbody> | |||
| 295 | + | </table> | |||
| 296 | + | </section> | |||
| 297 | + | <section aria-label="2026-03-19"> | |||
| 298 | + | <h2 class="rule-h"> | |||
| 299 | + | <span>2026-03-19</span> | |||
| 300 | + | </h2> | |||
| 301 | + | <table class="list commits" aria-label="Commits"> | |||
| 302 | + | <tbody data-rows> | |||
| 303 | + | <tr id="c-SHA7"> | |||
| 304 | + | <td class="cur" aria-hidden="true"> | |||
| 305 | + | </td> | |||
| 306 | + | <td class="c-sha mute">SHA7</td> | |||
| 307 | + | <td class="c-subject"> | |||
| 308 | + | <a href="/long/commit/SHA40">commit number 37</a> | |||
| 309 | + | </td> | |||
| 310 | + | <td class="c-who dim">T</td> | |||
| 311 | + | <td class="c-when nowrap dim"> | |||
| 312 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 313 | + | </td> | |||
| 314 | + | </tr> | |||
| 315 | + | <tr id="c-SHA7"> | |||
| 316 | + | <td class="cur" aria-hidden="true"> | |||
| 317 | + | </td> | |||
| 318 | + | <td class="c-sha mute">SHA7</td> | |||
| 319 | + | <td class="c-subject"> | |||
| 320 | + | <a href="/long/commit/SHA40">commit number 36</a> | |||
| 321 | + | </td> | |||
| 322 | + | <td class="c-who dim">T</td> | |||
| 323 | + | <td class="c-when nowrap dim"> | |||
| 324 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 325 | + | </td> | |||
| 326 | + | </tr> | |||
| 327 | + | </tbody> | |||
| 328 | + | </table> | |||
| 329 | + | </section> | |||
| 330 | + | <section aria-label="2026-03-18"> | |||
| 331 | + | <h2 class="rule-h"> | |||
| 332 | + | <span>2026-03-18</span> | |||
| 333 | + | </h2> | |||
| 334 | + | <table class="list commits" aria-label="Commits"> | |||
| 335 | + | <tbody data-rows> | |||
| 336 | + | <tr id="c-SHA7"> | |||
| 337 | + | <td class="cur" aria-hidden="true"> | |||
| 338 | + | </td> | |||
| 339 | + | <td class="c-sha mute">SHA7</td> | |||
| 340 | + | <td class="c-subject"> | |||
| 341 | + | <a href="/long/commit/SHA40">commit number 35</a> | |||
| 342 | + | </td> | |||
| 343 | + | <td class="c-who dim">T</td> | |||
| 344 | + | <td class="c-when nowrap dim"> | |||
| 345 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 346 | + | </td> | |||
| 347 | + | </tr> | |||
| 348 | + | <tr id="c-SHA7"> | |||
| 349 | + | <td class="cur" aria-hidden="true"> | |||
| 350 | + | </td> | |||
| 351 | + | <td class="c-sha mute">SHA7</td> | |||
| 352 | + | <td class="c-subject"> | |||
| 353 | + | <a href="/long/commit/SHA40">commit number 34</a> | |||
| 354 | + | </td> | |||
| 355 | + | <td class="c-who dim">T</td> | |||
| 356 | + | <td class="c-when nowrap dim"> | |||
| 357 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 358 | + | </td> | |||
| 359 | + | </tr> | |||
| 360 | + | </tbody> | |||
| 361 | + | </table> | |||
| 362 | + | </section> | |||
| 363 | + | <section aria-label="2026-03-17"> | |||
| 364 | + | <h2 class="rule-h"> | |||
| 365 | + | <span>2026-03-17</span> | |||
| 366 | + | </h2> | |||
| 367 | + | <table class="list commits" aria-label="Commits"> | |||
| 368 | + | <tbody data-rows> | |||
| 369 | + | <tr id="c-SHA7"> | |||
| 370 | + | <td class="cur" aria-hidden="true"> | |||
| 371 | + | </td> | |||
| 372 | + | <td class="c-sha mute">SHA7</td> | |||
| 373 | + | <td class="c-subject"> | |||
| 374 | + | <a href="/long/commit/SHA40">commit number 33</a> | |||
| 375 | + | </td> | |||
| 376 | + | <td class="c-who dim">T</td> | |||
| 377 | + | <td class="c-when nowrap dim"> | |||
| 378 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 379 | + | </td> | |||
| 380 | + | </tr> | |||
| 381 | + | <tr id="c-SHA7"> | |||
| 382 | + | <td class="cur" aria-hidden="true"> | |||
| 383 | + | </td> | |||
| 384 | + | <td class="c-sha mute">SHA7</td> | |||
| 385 | + | <td class="c-subject"> | |||
| 386 | + | <a href="/long/commit/SHA40">commit number 32</a> | |||
| 387 | + | </td> | |||
| 388 | + | <td class="c-who dim">T</td> | |||
| 389 | + | <td class="c-when nowrap dim"> | |||
| 390 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 391 | + | </td> | |||
| 392 | + | </tr> | |||
| 393 | + | </tbody> | |||
| 394 | + | </table> | |||
| 395 | + | </section> | |||
| 396 | + | <section aria-label="2026-03-16"> | |||
| 397 | + | <h2 class="rule-h"> | |||
| 398 | + | <span>2026-03-16</span> | |||
| 399 | + | </h2> | |||
| 400 | + | <table class="list commits" aria-label="Commits"> | |||
| 401 | + | <tbody data-rows> | |||
| 402 | + | <tr id="c-SHA7"> | |||
| 403 | + | <td class="cur" aria-hidden="true"> | |||
| 404 | + | </td> | |||
| 405 | + | <td class="c-sha mute">SHA7</td> | |||
| 406 | + | <td class="c-subject"> | |||
| 407 | + | <a href="/long/commit/SHA40">commit number 31</a> | |||
| 408 | + | </td> | |||
| 409 | + | <td class="c-who dim">T</td> | |||
| 410 | + | <td class="c-when nowrap dim"> | |||
| 411 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 412 | + | </td> | |||
| 413 | + | </tr> | |||
| 414 | + | <tr id="c-SHA7"> | |||
| 415 | + | <td class="cur" aria-hidden="true"> | |||
| 416 | + | </td> | |||
| 417 | + | <td class="c-sha mute">SHA7</td> | |||
| 418 | + | <td class="c-subject"> | |||
| 419 | + | <a href="/long/commit/SHA40">commit number 30</a> | |||
| 420 | + | </td> | |||
| 421 | + | <td class="c-who dim">T</td> | |||
| 422 | + | <td class="c-when nowrap dim"> | |||
| 423 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 424 | + | </td> | |||
| 425 | + | </tr> | |||
| 426 | + | </tbody> | |||
| 427 | + | </table> | |||
| 428 | + | </section> | |||
| 429 | + | <section aria-label="2026-03-15"> | |||
| 430 | + | <h2 class="rule-h"> | |||
| 431 | + | <span>2026-03-15</span> | |||
| 432 | + | </h2> | |||
| 433 | + | <table class="list commits" aria-label="Commits"> | |||
| 434 | + | <tbody data-rows> | |||
| 435 | + | <tr id="c-SHA7"> | |||
| 436 | + | <td class="cur" aria-hidden="true"> | |||
| 437 | + | </td> | |||
| 438 | + | <td class="c-sha mute">SHA7</td> | |||
| 439 | + | <td class="c-subject"> | |||
| 440 | + | <a href="/long/commit/SHA40">commit number 29</a> | |||
| 441 | + | </td> | |||
| 442 | + | <td class="c-who dim">T</td> | |||
| 443 | + | <td class="c-when nowrap dim"> | |||
| 444 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 445 | + | </td> | |||
| 446 | + | </tr> | |||
| 447 | + | <tr id="c-SHA7"> | |||
| 448 | + | <td class="cur" aria-hidden="true"> | |||
| 449 | + | </td> | |||
| 450 | + | <td class="c-sha mute">SHA7</td> | |||
| 451 | + | <td class="c-subject"> | |||
| 452 | + | <a href="/long/commit/SHA40">commit number 28</a> | |||
| 453 | + | </td> | |||
| 454 | + | <td class="c-who dim">T</td> | |||
| 455 | + | <td class="c-when nowrap dim"> | |||
| 456 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 457 | + | </td> | |||
| 458 | + | </tr> | |||
| 459 | + | </tbody> | |||
| 460 | + | </table> | |||
| 461 | + | </section> | |||
| 462 | + | <section aria-label="2026-03-14"> | |||
| 463 | + | <h2 class="rule-h"> | |||
| 464 | + | <span>2026-03-14</span> | |||
| 465 | + | </h2> | |||
| 466 | + | <table class="list commits" aria-label="Commits"> | |||
| 467 | + | <tbody data-rows> | |||
| 468 | + | <tr id="c-SHA7"> | |||
| 469 | + | <td class="cur" aria-hidden="true"> | |||
| 470 | + | </td> | |||
| 471 | + | <td class="c-sha mute">SHA7</td> | |||
| 472 | + | <td class="c-subject"> | |||
| 473 | + | <a href="/long/commit/SHA40">commit number 27</a> | |||
| 474 | + | </td> | |||
| 475 | + | <td class="c-who dim">T</td> | |||
| 476 | + | <td class="c-when nowrap dim"> | |||
| 477 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 478 | + | </td> | |||
| 479 | + | </tr> | |||
| 480 | + | <tr id="c-SHA7"> | |||
| 481 | + | <td class="cur" aria-hidden="true"> | |||
| 482 | + | </td> | |||
| 483 | + | <td class="c-sha mute">SHA7</td> | |||
| 484 | + | <td class="c-subject"> | |||
| 485 | + | <a href="/long/commit/SHA40">commit number 26</a> | |||
| 486 | + | </td> | |||
| 487 | + | <td class="c-who dim">T</td> | |||
| 488 | + | <td class="c-when nowrap dim"> | |||
| 489 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 490 | + | </td> | |||
| 491 | + | </tr> | |||
| 492 | + | </tbody> | |||
| 493 | + | </table> | |||
| 494 | + | </section> | |||
| 495 | + | <section aria-label="2026-03-13"> | |||
| 496 | + | <h2 class="rule-h"> | |||
| 497 | + | <span>2026-03-13</span> | |||
| 498 | + | </h2> | |||
| 499 | + | <table class="list commits" aria-label="Commits"> | |||
| 500 | + | <tbody data-rows> | |||
| 501 | + | <tr id="c-SHA7"> | |||
| 502 | + | <td class="cur" aria-hidden="true"> | |||
| 503 | + | </td> | |||
| 504 | + | <td class="c-sha mute">SHA7</td> | |||
| 505 | + | <td class="c-subject"> | |||
| 506 | + | <a href="/long/commit/SHA40">commit number 25</a> | |||
| 507 | + | </td> | |||
| 508 | + | <td class="c-who dim">T</td> | |||
| 509 | + | <td class="c-when nowrap dim"> | |||
| 510 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 511 | + | </td> | |||
| 512 | + | </tr> | |||
| 513 | + | <tr id="c-SHA7"> | |||
| 514 | + | <td class="cur" aria-hidden="true"> | |||
| 515 | + | </td> | |||
| 516 | + | <td class="c-sha mute">SHA7</td> | |||
| 517 | + | <td class="c-subject"> | |||
| 518 | + | <a href="/long/commit/SHA40">commit number 24</a> | |||
| 519 | + | </td> | |||
| 520 | + | <td class="c-who dim">T</td> | |||
| 521 | + | <td class="c-when nowrap dim"> | |||
| 522 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 523 | + | </td> | |||
| 524 | + | </tr> | |||
| 525 | + | </tbody> | |||
| 526 | + | </table> | |||
| 527 | + | </section> | |||
| 528 | + | <section aria-label="2026-03-12"> | |||
| 529 | + | <h2 class="rule-h"> | |||
| 530 | + | <span>2026-03-12</span> | |||
| 531 | + | </h2> | |||
| 532 | + | <table class="list commits" aria-label="Commits"> | |||
| 533 | + | <tbody data-rows> | |||
| 534 | + | <tr id="c-SHA7"> | |||
| 535 | + | <td class="cur" aria-hidden="true"> | |||
| 536 | + | </td> | |||
| 537 | + | <td class="c-sha mute">SHA7</td> | |||
| 538 | + | <td class="c-subject"> | |||
| 539 | + | <a href="/long/commit/SHA40">commit number 23</a> | |||
| 540 | + | </td> | |||
| 541 | + | <td class="c-who dim">T</td> | |||
| 542 | + | <td class="c-when nowrap dim"> | |||
| 543 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 544 | + | </td> | |||
| 545 | + | </tr> | |||
| 546 | + | <tr id="c-SHA7"> | |||
| 547 | + | <td class="cur" aria-hidden="true"> | |||
| 548 | + | </td> | |||
| 549 | + | <td class="c-sha mute">SHA7</td> | |||
| 550 | + | <td class="c-subject"> | |||
| 551 | + | <a href="/long/commit/SHA40">commit number 22</a> | |||
| 552 | + | </td> | |||
| 553 | + | <td class="c-who dim">T</td> | |||
| 554 | + | <td class="c-when nowrap dim"> | |||
| 555 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 556 | + | </td> | |||
| 557 | + | </tr> | |||
| 558 | + | </tbody> | |||
| 559 | + | </table> | |||
| 560 | + | </section> | |||
| 561 | + | <section aria-label="2026-03-11"> | |||
| 562 | + | <h2 class="rule-h"> | |||
| 563 | + | <span>2026-03-11</span> | |||
| 564 | + | </h2> | |||
| 565 | + | <table class="list commits" aria-label="Commits"> | |||
| 566 | + | <tbody data-rows> | |||
| 567 | + | <tr id="c-SHA7"> | |||
| 568 | + | <td class="cur" aria-hidden="true"> | |||
| 569 | + | </td> | |||
| 570 | + | <td class="c-sha mute">SHA7</td> | |||
| 571 | + | <td class="c-subject"> | |||
| 572 | + | <a href="/long/commit/SHA40">commit number 21</a> | |||
| 573 | + | </td> | |||
| 574 | + | <td class="c-who dim">T</td> | |||
| 575 | + | <td class="c-when nowrap dim"> | |||
| 576 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 577 | + | </td> | |||
| 578 | + | </tr> | |||
| 579 | + | <tr id="c-SHA7"> | |||
| 580 | + | <td class="cur" aria-hidden="true"> | |||
| 581 | + | </td> | |||
| 582 | + | <td class="c-sha mute">SHA7</td> | |||
| 583 | + | <td class="c-subject"> | |||
| 584 | + | <a href="/long/commit/SHA40">commit number 20</a> | |||
| 585 | + | </td> | |||
| 586 | + | <td class="c-who dim">T</td> | |||
| 587 | + | <td class="c-when nowrap dim"> | |||
| 588 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 589 | + | </td> | |||
| 590 | + | </tr> | |||
| 591 | + | </tbody> | |||
| 592 | + | </table> | |||
| 593 | + | </section> | |||
| 594 | + | <section aria-label="2026-03-10"> | |||
| 595 | + | <h2 class="rule-h"> | |||
| 596 | + | <span>2026-03-10</span> | |||
| 597 | + | </h2> | |||
| 598 | + | <table class="list commits" aria-label="Commits"> | |||
| 599 | + | <tbody data-rows> | |||
| 600 | + | <tr id="c-SHA7"> | |||
| 601 | + | <td class="cur" aria-hidden="true"> | |||
| 602 | + | </td> | |||
| 603 | + | <td class="c-sha mute">SHA7</td> | |||
| 604 | + | <td class="c-subject"> | |||
| 605 | + | <a href="/long/commit/SHA40">commit number 19</a> | |||
| 606 | + | </td> | |||
| 607 | + | <td class="c-who dim">T</td> | |||
| 608 | + | <td class="c-when nowrap dim"> | |||
| 609 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 610 | + | </td> | |||
| 611 | + | </tr> | |||
| 612 | + | <tr id="c-SHA7"> | |||
| 613 | + | <td class="cur" aria-hidden="true"> | |||
| 614 | + | </td> | |||
| 615 | + | <td class="c-sha mute">SHA7</td> | |||
| 616 | + | <td class="c-subject"> | |||
| 617 | + | <a href="/long/commit/SHA40">commit number 18</a> | |||
| 618 | + | </td> | |||
| 619 | + | <td class="c-who dim">T</td> | |||
| 620 | + | <td class="c-when nowrap dim"> | |||
| 621 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 622 | + | </td> | |||
| 623 | + | </tr> | |||
| 624 | + | </tbody> | |||
| 625 | + | </table> | |||
| 626 | + | </section> | |||
| 627 | + | <section aria-label="2026-03-09"> | |||
| 628 | + | <h2 class="rule-h"> | |||
| 629 | + | <span>2026-03-09</span> | |||
| 630 | + | </h2> | |||
| 631 | + | <table class="list commits" aria-label="Commits"> | |||
| 632 | + | <tbody data-rows> | |||
| 633 | + | <tr id="c-SHA7"> | |||
| 634 | + | <td class="cur" aria-hidden="true"> | |||
| 635 | + | </td> | |||
| 636 | + | <td class="c-sha mute">SHA7</td> | |||
| 637 | + | <td class="c-subject"> | |||
| 638 | + | <a href="/long/commit/SHA40">commit number 17</a> | |||
| 639 | + | </td> | |||
| 640 | + | <td class="c-who dim">T</td> | |||
| 641 | + | <td class="c-when nowrap dim"> | |||
| 642 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 643 | + | </td> | |||
| 644 | + | </tr> | |||
| 645 | + | <tr id="c-SHA7"> | |||
| 646 | + | <td class="cur" aria-hidden="true"> | |||
| 647 | + | </td> | |||
| 648 | + | <td class="c-sha mute">SHA7</td> | |||
| 649 | + | <td class="c-subject"> | |||
| 650 | + | <a href="/long/commit/SHA40">commit number 16</a> | |||
| 651 | + | </td> | |||
| 652 | + | <td class="c-who dim">T</td> | |||
| 653 | + | <td class="c-when nowrap dim"> | |||
| 654 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 655 | + | </td> | |||
| 656 | + | </tr> | |||
| 657 | + | </tbody> | |||
| 658 | + | </table> | |||
| 659 | + | </section> | |||
| 660 | + | <section aria-label="2026-03-08"> | |||
| 661 | + | <h2 class="rule-h"> | |||
| 662 | + | <span>2026-03-08</span> | |||
| 663 | + | </h2> | |||
| 664 | + | <table class="list commits" aria-label="Commits"> | |||
| 665 | + | <tbody data-rows> | |||
| 666 | + | <tr id="c-SHA7"> | |||
| 667 | + | <td class="cur" aria-hidden="true"> | |||
| 668 | + | </td> | |||
| 669 | + | <td class="c-sha mute">SHA7</td> | |||
| 670 | + | <td class="c-subject"> | |||
| 671 | + | <a href="/long/commit/SHA40">commit number 15</a> | |||
| 672 | + | </td> | |||
| 673 | + | <td class="c-who dim">T</td> | |||
| 674 | + | <td class="c-when nowrap dim"> | |||
| 675 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 676 | + | </td> | |||
| 677 | + | </tr> | |||
| 678 | + | <tr id="c-SHA7"> | |||
| 679 | + | <td class="cur" aria-hidden="true"> | |||
| 680 | + | </td> | |||
| 681 | + | <td class="c-sha mute">SHA7</td> | |||
| 682 | + | <td class="c-subject"> | |||
| 683 | + | <a href="/long/commit/SHA40">commit number 14</a> | |||
| 684 | + | </td> | |||
| 685 | + | <td class="c-who dim">T</td> | |||
| 686 | + | <td class="c-when nowrap dim"> | |||
| 687 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 688 | + | </td> | |||
| 689 | + | </tr> | |||
| 690 | + | </tbody> | |||
| 691 | + | </table> | |||
| 692 | + | </section> | |||
| 693 | + | <section aria-label="2026-03-07"> | |||
| 694 | + | <h2 class="rule-h"> | |||
| 695 | + | <span>2026-03-07</span> | |||
| 696 | + | </h2> | |||
| 697 | + | <table class="list commits" aria-label="Commits"> | |||
| 698 | + | <tbody data-rows> | |||
| 699 | + | <tr id="c-SHA7"> | |||
| 700 | + | <td class="cur" aria-hidden="true"> | |||
| 701 | + | </td> | |||
| 702 | + | <td class="c-sha mute">SHA7</td> | |||
| 703 | + | <td class="c-subject"> | |||
| 704 | + | <a href="/long/commit/SHA40">commit number 13</a> | |||
| 705 | + | </td> | |||
| 706 | + | <td class="c-who dim">T</td> | |||
| 707 | + | <td class="c-when nowrap dim"> | |||
| 708 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 709 | + | </td> | |||
| 710 | + | </tr> | |||
| 711 | + | <tr id="c-SHA7"> | |||
| 712 | + | <td class="cur" aria-hidden="true"> | |||
| 713 | + | </td> | |||
| 714 | + | <td class="c-sha mute">SHA7</td> | |||
| 715 | + | <td class="c-subject"> | |||
| 716 | + | <a href="/long/commit/SHA40">commit number 12</a> | |||
| 717 | + | </td> | |||
| 718 | + | <td class="c-who dim">T</td> | |||
| 719 | + | <td class="c-when nowrap dim"> | |||
| 720 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 721 | + | </td> | |||
| 722 | + | </tr> | |||
| 723 | + | </tbody> | |||
| 724 | + | </table> | |||
| 725 | + | </section> | |||
| 726 | + | <section aria-label="2026-03-06"> | |||
| 727 | + | <h2 class="rule-h"> | |||
| 728 | + | <span>2026-03-06</span> | |||
| 729 | + | </h2> | |||
| 730 | + | <table class="list commits" aria-label="Commits"> | |||
| 731 | + | <tbody data-rows> | |||
| 732 | + | <tr id="c-SHA7"> | |||
| 733 | + | <td class="cur" aria-hidden="true"> | |||
| 734 | + | </td> | |||
| 735 | + | <td class="c-sha mute">SHA7</td> | |||
| 736 | + | <td class="c-subject"> | |||
| 737 | + | <a href="/long/commit/SHA40">commit number 11</a> | |||
| 738 | + | </td> | |||
| 739 | + | <td class="c-who dim">T</td> | |||
| 740 | + | <td class="c-when nowrap dim"> | |||
| 741 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 742 | + | </td> | |||
| 743 | + | </tr> | |||
| 744 | + | <tr id="c-SHA7"> | |||
| 745 | + | <td class="cur" aria-hidden="true"> | |||
| 746 | + | </td> | |||
| 747 | + | <td class="c-sha mute">SHA7</td> | |||
| 748 | + | <td class="c-subject"> | |||
| 749 | + | <a href="/long/commit/SHA40">commit number 10</a> | |||
| 750 | + | </td> | |||
| 751 | + | <td class="c-who dim">T</td> | |||
| 752 | + | <td class="c-when nowrap dim"> | |||
| 753 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 754 | + | </td> | |||
| 755 | + | </tr> | |||
| 756 | + | </tbody> | |||
| 757 | + | </table> | |||
| 758 | + | </section> | |||
| 759 | + | <section aria-label="2026-03-05"> | |||
| 760 | + | <h2 class="rule-h"> | |||
| 761 | + | <span>2026-03-05</span> | |||
| 762 | + | </h2> | |||
| 763 | + | <table class="list commits" aria-label="Commits"> | |||
| 764 | + | <tbody data-rows> | |||
| 765 | + | <tr id="c-SHA7"> | |||
| 766 | + | <td class="cur" aria-hidden="true"> | |||
| 767 | + | </td> | |||
| 768 | + | <td class="c-sha mute">SHA7</td> | |||
| 769 | + | <td class="c-subject"> | |||
| 770 | + | <a href="/long/commit/SHA40">commit number 9</a> | |||
| 771 | + | </td> | |||
| 772 | + | <td class="c-who dim">T</td> | |||
| 773 | + | <td class="c-when nowrap dim"> | |||
| 774 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 775 | + | </td> | |||
| 776 | + | </tr> | |||
| 777 | + | <tr id="c-SHA7"> | |||
| 778 | + | <td class="cur" aria-hidden="true"> | |||
| 779 | + | </td> | |||
| 780 | + | <td class="c-sha mute">SHA7</td> | |||
| 781 | + | <td class="c-subject"> | |||
| 782 | + | <a href="/long/commit/SHA40">commit number 8</a> | |||
| 783 | + | </td> | |||
| 784 | + | <td class="c-who dim">T</td> | |||
| 785 | + | <td class="c-when nowrap dim"> | |||
| 786 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 787 | + | </td> | |||
| 788 | + | </tr> | |||
| 789 | + | </tbody> | |||
| 790 | + | </table> | |||
| 791 | + | </section> | |||
| 792 | + | <section aria-label="2026-03-04"> | |||
| 793 | + | <h2 class="rule-h"> | |||
| 794 | + | <span>2026-03-04</span> | |||
| 795 | + | </h2> | |||
| 796 | + | <table class="list commits" aria-label="Commits"> | |||
| 797 | + | <tbody data-rows> | |||
| 798 | + | <tr id="c-SHA7"> | |||
| 799 | + | <td class="cur" aria-hidden="true"> | |||
| 800 | + | </td> | |||
| 801 | + | <td class="c-sha mute">SHA7</td> | |||
| 802 | + | <td class="c-subject"> | |||
| 803 | + | <a href="/long/commit/SHA40">commit number 7</a> | |||
| 804 | + | </td> | |||
| 805 | + | <td class="c-who dim">T</td> | |||
| 806 | + | <td class="c-when nowrap dim"> | |||
| 807 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 808 | + | </td> | |||
| 809 | + | </tr> | |||
| 810 | + | <tr id="c-SHA7"> | |||
| 811 | + | <td class="cur" aria-hidden="true"> | |||
| 812 | + | </td> | |||
| 813 | + | <td class="c-sha mute">SHA7</td> | |||
| 814 | + | <td class="c-subject"> | |||
| 815 | + | <a href="/long/commit/SHA40">commit number 6</a> | |||
| 816 | + | </td> | |||
| 817 | + | <td class="c-who dim">T</td> | |||
| 818 | + | <td class="c-when nowrap dim"> | |||
| 819 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 820 | + | </td> | |||
| 821 | + | </tr> | |||
| 822 | + | </tbody> | |||
| 823 | + | </table> | |||
| 824 | + | </section> | |||
| 825 | + | <section aria-label="2026-03-03"> | |||
| 826 | + | <h2 class="rule-h"> | |||
| 827 | + | <span>2026-03-03</span> | |||
| 828 | + | </h2> | |||
| 829 | + | <table class="list commits" aria-label="Commits"> | |||
| 830 | + | <tbody data-rows> | |||
| 831 | + | <tr id="c-SHA7"> | |||
| 832 | + | <td class="cur" aria-hidden="true"> | |||
| 833 | + | </td> | |||
| 834 | + | <td class="c-sha mute">SHA7</td> | |||
| 835 | + | <td class="c-subject"> | |||
| 836 | + | <a href="/long/commit/SHA40">commit number 5</a> | |||
| 837 | + | </td> | |||
| 838 | + | <td class="c-who dim">T</td> | |||
| 839 | + | <td class="c-when nowrap dim"> | |||
| 840 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 841 | + | </td> | |||
| 842 | + | </tr> | |||
| 843 | + | <tr id="c-SHA7"> | |||
| 844 | + | <td class="cur" aria-hidden="true"> | |||
| 845 | + | </td> | |||
| 846 | + | <td class="c-sha mute">SHA7</td> | |||
| 847 | + | <td class="c-subject"> | |||
| 848 | + | <a href="/long/commit/SHA40">commit number 4</a> | |||
| 849 | + | </td> | |||
| 850 | + | <td class="c-who dim">T</td> | |||
| 851 | + | <td class="c-when nowrap dim"> | |||
| 852 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 853 | + | </td> | |||
| 854 | + | </tr> | |||
| 855 | + | </tbody> | |||
| 856 | + | </table> | |||
| 857 | + | </section> | |||
| 858 | + | <section aria-label="2026-03-02"> | |||
| 859 | + | <h2 class="rule-h"> | |||
| 860 | + | <span>2026-03-02</span> | |||
| 861 | + | </h2> | |||
| 862 | + | <table class="list commits" aria-label="Commits"> | |||
| 863 | + | <tbody data-rows> | |||
| 864 | + | <tr id="c-SHA7"> | |||
| 865 | + | <td class="cur" aria-hidden="true"> | |||
| 866 | + | </td> | |||
| 867 | + | <td class="c-sha mute">SHA7</td> | |||
| 868 | + | <td class="c-subject"> | |||
| 869 | + | <a href="/long/commit/SHA40">commit number 3</a> | |||
| 870 | + | </td> | |||
| 871 | + | <td class="c-who dim">T</td> | |||
| 872 | + | <td class="c-when nowrap dim"> | |||
| 873 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 874 | + | </td> | |||
| 875 | + | </tr> | |||
| 876 | + | <tr id="c-SHA7"> | |||
| 877 | + | <td class="cur" aria-hidden="true"> | |||
| 878 | + | </td> | |||
| 879 | + | <td class="c-sha mute">SHA7</td> | |||
| 880 | + | <td class="c-subject"> | |||
| 881 | + | <a href="/long/commit/SHA40">commit number 2</a> | |||
| 882 | + | </td> | |||
| 883 | + | <td class="c-who dim">T</td> | |||
| 884 | + | <td class="c-when nowrap dim"> | |||
| 885 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 886 | + | </td> | |||
| 887 | + | </tr> | |||
| 888 | + | </tbody> | |||
| 889 | + | </table> | |||
| 890 | + | </section> | |||
| 891 | + | </div> | |||
| 892 | + | <nav class="pager" aria-label="Pages"> | |||
| 893 | + | <span class="mute">← newest</span> | |||
| 894 | + | <a href="/long/commits/main?page=YzE6MzMzZGFjMWY5YmY4MjIzMmQxOTAzNGVlZTliZmI2NzFmM2QxMWEzNDo1MDplM2IwYzQ0Mjk4ZmM" rel="next">older →</a> | |||
| 895 | + | </nav> | |||
| 896 | + | </main> | |||
| 897 | + | <aside class="rail" aria-label="Context"> | |||
| 898 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 899 | + | <summary>history · refs on this page</summary> | |||
| 900 | + | <section aria-labelledby="r-hist"> | |||
| 901 | + | <h2 id="r-hist">history</h2> | |||
| 902 | + | <dl class="kv"> | |||
| 903 | + | <dt>ref</dt> | |||
| 904 | + | <dd>main</dd> | |||
| 905 | + | <dt>on page</dt> | |||
| 906 | + | <dd>50</dd> | |||
| 907 | + | </dl> | |||
| 908 | + | </section> | |||
| 909 | + | <section aria-labelledby="r-refs"> | |||
| 910 | + | <h2 id="r-refs">refs on this page</h2> | |||
| 911 | + | <nav class="outline" aria-label="Refs on this page"> | |||
| 912 | + | <a href="#c-SHA7">branch main</a> | |||
| 913 | + | </nav> | |||
| 914 | + | </section> | |||
| 915 | + | </details> | |||
| 916 | + | </aside> | |||
| 917 | + | </div> | |||
| 918 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 919 | + | <div class="panel-head"> | |||
| 920 | + | <span>keys</span> | |||
| 921 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 922 | + | </div> | |||
| 923 | + | <dl class="keys"> | |||
| 924 | + | <div> | |||
| 925 | + | <dt> | |||
| 926 | + | <kbd>j</kbd> | |||
| 927 | + | <kbd>k</kbd> | |||
| 928 | + | </dt> | |||
| 929 | + | <dd>move cursor down / up</dd> | |||
| 930 | + | </div> | |||
| 931 | + | <div> | |||
| 932 | + | <dt> | |||
| 933 | + | <kbd>enter</kbd> | |||
| 934 | + | </dt> | |||
| 935 | + | <dd>open row under cursor</dd> | |||
| 936 | + | </div> | |||
| 937 | + | <div> | |||
| 938 | + | <dt> | |||
| 939 | + | <kbd>g</kbd> | |||
| 940 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 941 | + | </dt> | |||
| 942 | + | <dd>first / last row</dd> | |||
| 943 | + | </div> | |||
| 944 | + | <div> | |||
| 945 | + | <dt> | |||
| 946 | + | <kbd>/</kbd> | |||
| 947 | + | </dt> | |||
| 948 | + | <dd>focus the filter</dd> | |||
| 949 | + | </div> | |||
| 950 | + | <div> | |||
| 951 | + | <dt> | |||
| 952 | + | <kbd>g h</kbd> | |||
| 953 | + | <kbd>g t</kbd> | |||
| 954 | + | </dt> | |||
| 955 | + | <dd>go home / tree</dd> | |||
| 956 | + | </div> | |||
| 957 | + | <div> | |||
| 958 | + | <dt> | |||
| 959 | + | <kbd>g c</kbd> | |||
| 960 | + | <kbd>g r</kbd> | |||
| 961 | + | </dt> | |||
| 962 | + | <dd>go commits / refs</dd> | |||
| 963 | + | </div> | |||
| 964 | + | <div> | |||
| 965 | + | <dt> | |||
| 966 | + | <kbd>b</kbd> | |||
| 967 | + | </dt> | |||
| 968 | + | <dd>switch ref (branches, tags)</dd> | |||
| 969 | + | </div> | |||
| 970 | + | <div> | |||
| 971 | + | <dt> | |||
| 972 | + | <kbd>:</kbd> | |||
| 973 | + | <kbd>f</kbd> | |||
| 974 | + | </dt> | |||
| 975 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 976 | + | </div> | |||
| 977 | + | <div> | |||
| 978 | + | <dt> | |||
| 979 | + | <kbd>y</kbd> | |||
| 980 | + | </dt> | |||
| 981 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 982 | + | </div> | |||
| 983 | + | <div> | |||
| 984 | + | <dt> | |||
| 985 | + | <kbd>s</kbd> | |||
| 986 | + | <kbd>t</kbd> | |||
| 987 | + | <kbd>c</kbd> | |||
| 988 | + | <kbd>r</kbd> | |||
| 989 | + | </dt> | |||
| 990 | + | <dd>repo sections (when shown)</dd> | |||
| 991 | + | </div> | |||
| 992 | + | <div> | |||
| 993 | + | <dt> | |||
| 994 | + | <kbd>d</kbd> | |||
| 995 | + | <kbd>u</kbd> | |||
| 996 | + | <kbd>x</kbd> | |||
| 997 | + | </dt> | |||
| 998 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 999 | + | </div> | |||
| 1000 | + | <div> | |||
| 1001 | + | <dt> | |||
| 1002 | + | <kbd>esc</kbd> | |||
| 1003 | + | </dt> | |||
| 1004 | + | <dd>close panel</dd> | |||
| 1005 | + | </div> | |||
| 1006 | + | </dl> | |||
| 1007 | + | </dialog> | |||
| 1008 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 1009 | + | <div class="panel-head"> | |||
| 1010 | + | <label for="refs-filter">ref></label> | |||
| 1011 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |||
| 1012 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 1013 | + | </div> | |||
| 1014 | + | <div class="panel-cols" data-picklist> | |||
| 1015 | + | <section aria-label="Branches"> | |||
| 1016 | + | <h2>branches <span class="mute">1</span> | |||
| 1017 | + | </h2> | |||
| 1018 | + | <ul> | |||
| 1019 | + | <li> | |||
| 1020 | + | <a href="/long/tree/main" aria-current="true"> | |||
| 1021 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 1022 | + | </li> | |||
| 1023 | + | </ul> | |||
| 1024 | + | </section> | |||
| 1025 | + | <section aria-label="Tags"> | |||
| 1026 | + | <h2>tags <span class="mute">0</span> | |||
| 1027 | + | </h2> | |||
| 1028 | + | <ul> | |||
| 1029 | + | </ul> | |||
| 1030 | + | </section> | |||
| 1031 | + | </div> | |||
| 1032 | + | </dialog> | |||
| 1033 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 1034 | + | <div class="panel-head"> | |||
| 1035 | + | <label for="cmd-q" class="hi">:</label> | |||
| 1036 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 1037 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 1038 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 1039 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 1040 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 1041 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 1042 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 1043 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 1044 | + | </div> | |||
| 1045 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 1046 | + | <li data-k="go home" data-cursor> | |||
| 1047 | + | <a href="/" tabindex="-1"> | |||
| 1048 | + | <span class="k">go</span>home</a> | |||
| 1049 | + | </li> | |||
| 1050 | + | <li data-k="section summary"> | |||
| 1051 | + | <a href="/long" tabindex="-1"> | |||
| 1052 | + | <span class="k">section</span>summary</a> | |||
| 1053 | + | </li> | |||
| 1054 | + | <li data-k="section tree"> | |||
| 1055 | + | <a href="/long/tree/main" tabindex="-1"> | |||
| 1056 | + | <span class="k">section</span>tree</a> | |||
| 1057 | + | </li> | |||
| 1058 | + | <li data-k="section commits"> | |||
| 1059 | + | <a href="/long/commits/main" tabindex="-1"> | |||
| 1060 | + | <span class="k">section</span>commits</a> | |||
| 1061 | + | </li> | |||
| 1062 | + | <li data-k="section refs"> | |||
| 1063 | + | <a href="/long/refs" tabindex="-1"> | |||
| 1064 | + | <span class="k">section</span>refs</a> | |||
| 1065 | + | </li> | |||
| 1066 | + | <li data-k="branch main"> | |||
| 1067 | + | <a href="/long/tree/main" tabindex="-1"> | |||
| 1068 | + | <span class="k">branch</span>main</a> | |||
| 1069 | + | </li> | |||
| 1070 | + | <li data-k="repo diffs"> | |||
| 1071 | + | <a href="/diffs" tabindex="-1"> | |||
| 1072 | + | <span class="k">repo</span>diffs</a> | |||
| 1073 | + | </li> | |||
| 1074 | + | </ul> | |||
| 1075 | + | </dialog> | |||
| 1076 | + | <footer class="bar status" aria-label="Status"> | |||
| 1077 | + | <span class="mode">commits</span> | |||
| 1078 | + | <span class="st-ctx">main · 50 commits · more</span> | |||
| 1079 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 1080 | + | <span class="hi">:</span> go to…</button> | |||
| 1081 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 1082 | + | </span> | |||
| 1083 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |||
| 1084 | + | <span class="st-src"> | |||
| 1085 | + | <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> | |||
| 1087 | + | </span> | |||
| 1088 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">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> | |||
| 1090 | + | </footer> | |||
| 1091 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 1092 | + | if (q('dialog[open]').count) return; | |||
| 1093 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 1094 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 1095 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 1096 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 1097 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 1098 | + | })(1); | |||
| 1099 | + | k-k from:body -> ((d) => { | |||
| 1100 | + | if (q('dialog[open]').count) return; | |||
| 1101 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 1102 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 1103 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 1104 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 1105 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 1106 | + | })(-1); | |||
| 1107 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 1108 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 1109 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 1110 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 1111 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 1112 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |||
| 1113 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 1114 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 1115 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 1116 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 1117 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 1118 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 1119 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 1120 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 1121 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 1122 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 1123 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 1124 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 1125 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 1126 | + | k-permalink from:body -> { | |||
| 1127 | + | let b = document.body.dataset; | |||
| 1128 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 1129 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 1130 | + | navigator.clipboard?.writeText(location.href); | |||
| 1131 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 1132 | + | }"> | |||
| 1133 | + | </div> | |||
| 1134 | + | </body> | |||
| 1135 | + | </html> | |||
A cmd/ermite/testdata/web/commits-small.html +495 -0
| @@ -0,0 +1,495 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="light dark"> | |
| 7 | + | <title>commits · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commits</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/diffs/commits/main" aria-current="page">main</a> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <div class="filter"> | |
| 59 | + | <label for="commit-filter">filter></label> | |
| 60 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 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')"> | |
| 63 | + | </div> | |
| 64 | + | <div class="days" id="commits-head"> | |
| 65 | + | <section aria-label="2026-02-08"> | |
| 66 | + | <h2 class="rule-h"> | |
| 67 | + | <span>2026-02-08</span> | |
| 68 | + | </h2> | |
| 69 | + | <table class="list commits" aria-label="Commits"> | |
| 70 | + | <tbody data-rows> | |
| 71 | + | <tr id="c-SHA7" data-cursor> | |
| 72 | + | <td class="cur" aria-hidden="true"> | |
| 73 | + | </td> | |
| 74 | + | <td class="c-sha mute">SHA7</td> | |
| 75 | + | <td class="c-subject"> | |
| 76 | + | <a href="/diffs/commit/SHA40">add a very large file</a> | |
| 77 | + | <span class="tagged">[main]</span> | |
| 78 | + | </td> | |
| 79 | + | <td class="c-who dim">T</td> | |
| 80 | + | <td class="c-when nowrap dim"> | |
| 81 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 82 | + | </td> | |
| 83 | + | </tr> | |
| 84 | + | </tbody> | |
| 85 | + | </table> | |
| 86 | + | </section> | |
| 87 | + | <section aria-label="2026-02-07"> | |
| 88 | + | <h2 class="rule-h"> | |
| 89 | + | <span>2026-02-07</span> | |
| 90 | + | </h2> | |
| 91 | + | <table class="list commits" aria-label="Commits"> | |
| 92 | + | <tbody data-rows> | |
| 93 | + | <tr id="c-SHA7"> | |
| 94 | + | <td class="cur" aria-hidden="true"> | |
| 95 | + | </td> | |
| 96 | + | <td class="c-sha mute">SHA7</td> | |
| 97 | + | <td class="c-subject"> | |
| 98 | + | <a href="/diffs/commit/SHA40">add evil.go</a> | |
| 99 | + | </td> | |
| 100 | + | <td class="c-who dim">T</td> | |
| 101 | + | <td class="c-when nowrap dim"> | |
| 102 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 103 | + | </td> | |
| 104 | + | </tr> | |
| 105 | + | </tbody> | |
| 106 | + | </table> | |
| 107 | + | </section> | |
| 108 | + | <section aria-label="2026-02-06"> | |
| 109 | + | <h2 class="rule-h"> | |
| 110 | + | <span>2026-02-06</span> | |
| 111 | + | </h2> | |
| 112 | + | <table class="list commits" aria-label="Commits"> | |
| 113 | + | <tbody data-rows> | |
| 114 | + | <tr id="c-SHA7"> | |
| 115 | + | <td class="cur" aria-hidden="true"> | |
| 116 | + | </td> | |
| 117 | + | <td class="c-sha mute">SHA7</td> | |
| 118 | + | <td class="c-subject"> | |
| 119 | + | <a href="/diffs/commit/SHA40">merge the side branch</a> | |
| 120 | + | <span class="tagged">(merge)</span> | |
| 121 | + | </td> | |
| 122 | + | <td class="c-who dim">T</td> | |
| 123 | + | <td class="c-when nowrap dim"> | |
| 124 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 125 | + | </td> | |
| 126 | + | </tr> | |
| 127 | + | </tbody> | |
| 128 | + | </table> | |
| 129 | + | </section> | |
| 130 | + | <section aria-label="2026-02-05"> | |
| 131 | + | <h2 class="rule-h"> | |
| 132 | + | <span>2026-02-05</span> | |
| 133 | + | </h2> | |
| 134 | + | <table class="list commits" aria-label="Commits"> | |
| 135 | + | <tbody data-rows> | |
| 136 | + | <tr id="c-SHA7"> | |
| 137 | + | <td class="cur" aria-hidden="true"> | |
| 138 | + | </td> | |
| 139 | + | <td class="c-sha mute">SHA7</td> | |
| 140 | + | <td class="c-subject"> | |
| 141 | + | <a href="/diffs/commit/SHA40">work on the side</a> | |
| 142 | + | <span class="tagged">[side]</span> | |
| 143 | + | </td> | |
| 144 | + | <td class="c-who dim">T</td> | |
| 145 | + | <td class="c-when nowrap dim"> | |
| 146 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 147 | + | </td> | |
| 148 | + | </tr> | |
| 149 | + | </tbody> | |
| 150 | + | </table> | |
| 151 | + | </section> | |
| 152 | + | <section aria-label="2026-02-04"> | |
| 153 | + | <h2 class="rule-h"> | |
| 154 | + | <span>2026-02-04</span> | |
| 155 | + | </h2> | |
| 156 | + | <table class="list commits" aria-label="Commits"> | |
| 157 | + | <tbody data-rows> | |
| 158 | + | <tr id="c-SHA7"> | |
| 159 | + | <td class="cur" aria-hidden="true"> | |
| 160 | + | </td> | |
| 161 | + | <td class="c-sha mute">SHA7</td> | |
| 162 | + | <td class="c-subject"> | |
| 163 | + | <a href="/diffs/commit/SHA40">change a binary file</a> | |
| 164 | + | </td> | |
| 165 | + | <td class="c-who dim">T</td> | |
| 166 | + | <td class="c-when nowrap dim"> | |
| 167 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 168 | + | </td> | |
| 169 | + | </tr> | |
| 170 | + | </tbody> | |
| 171 | + | </table> | |
| 172 | + | </section> | |
| 173 | + | <section aria-label="2026-02-03"> | |
| 174 | + | <h2 class="rule-h"> | |
| 175 | + | <span>2026-02-03</span> | |
| 176 | + | </h2> | |
| 177 | + | <table class="list commits" aria-label="Commits"> | |
| 178 | + | <tbody data-rows> | |
| 179 | + | <tr id="c-SHA7"> | |
| 180 | + | <td class="cur" aria-hidden="true"> | |
| 181 | + | </td> | |
| 182 | + | <td class="c-sha mute">SHA7</td> | |
| 183 | + | <td class="c-subject"> | |
| 184 | + | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | |
| 185 | + | </td> | |
| 186 | + | <td class="c-who dim">T</td> | |
| 187 | + | <td class="c-when nowrap dim"> | |
| 188 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 189 | + | </td> | |
| 190 | + | </tr> | |
| 191 | + | </tbody> | |
| 192 | + | </table> | |
| 193 | + | </section> | |
| 194 | + | <section aria-label="2026-02-02"> | |
| 195 | + | <h2 class="rule-h"> | |
| 196 | + | <span>2026-02-02</span> | |
| 197 | + | </h2> | |
| 198 | + | <table class="list commits" aria-label="Commits"> | |
| 199 | + | <tbody data-rows> | |
| 200 | + | <tr id="c-SHA7"> | |
| 201 | + | <td class="cur" aria-hidden="true"> | |
| 202 | + | </td> | |
| 203 | + | <td class="c-sha mute">SHA7</td> | |
| 204 | + | <td class="c-subject"> | |
| 205 | + | <a href="/diffs/commit/SHA40">modify a file and make a script executable</a> | |
| 206 | + | <span class="tagged">[tag: v1.0.0]</span> | |
| 207 | + | </td> | |
| 208 | + | <td class="c-who dim">T</td> | |
| 209 | + | <td class="c-when nowrap dim"> | |
| 210 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 211 | + | </td> | |
| 212 | + | </tr> | |
| 213 | + | </tbody> | |
| 214 | + | </table> | |
| 215 | + | </section> | |
| 216 | + | <section aria-label="2026-02-01"> | |
| 217 | + | <h2 class="rule-h"> | |
| 218 | + | <span>2026-02-01</span> | |
| 219 | + | </h2> | |
| 220 | + | <table class="list commits" aria-label="Commits"> | |
| 221 | + | <tbody data-rows> | |
| 222 | + | <tr id="c-SHA7"> | |
| 223 | + | <td class="cur" aria-hidden="true"> | |
| 224 | + | </td> | |
| 225 | + | <td class="c-sha mute">SHA7</td> | |
| 226 | + | <td class="c-subject"> | |
| 227 | + | <a href="/diffs/commit/SHA40">root: the first files</a> | |
| 228 | + | </td> | |
| 229 | + | <td class="c-who dim">T</td> | |
| 230 | + | <td class="c-when nowrap dim"> | |
| 231 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 232 | + | </td> | |
| 233 | + | </tr> | |
| 234 | + | </tbody> | |
| 235 | + | </table> | |
| 236 | + | </section> | |
| 237 | + | </div> | |
| 238 | + | </main> | |
| 239 | + | <aside class="rail" aria-label="Context"> | |
| 240 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 241 | + | <summary>history · refs on this page</summary> | |
| 242 | + | <section aria-labelledby="r-hist"> | |
| 243 | + | <h2 id="r-hist">history</h2> | |
| 244 | + | <dl class="kv"> | |
| 245 | + | <dt>ref</dt> | |
| 246 | + | <dd>main</dd> | |
| 247 | + | <dt>on page</dt> | |
| 248 | + | <dd>8</dd> | |
| 249 | + | </dl> | |
| 250 | + | </section> | |
| 251 | + | <section aria-labelledby="r-refs"> | |
| 252 | + | <h2 id="r-refs">refs on this page</h2> | |
| 253 | + | <nav class="outline" aria-label="Refs on this page"> | |
| 254 | + | <a href="#c-SHA7">branch main</a> | |
| 255 | + | <a href="#c-SHA7">branch side</a> | |
| 256 | + | <a href="#c-SHA7">tag v1.0.0</a> | |
| 257 | + | </nav> | |
| 258 | + | </section> | |
| 259 | + | </details> | |
| 260 | + | </aside> | |
| 261 | + | </div> | |
| 262 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 263 | + | <div class="panel-head"> | |
| 264 | + | <span>keys</span> | |
| 265 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 266 | + | </div> | |
| 267 | + | <dl class="keys"> | |
| 268 | + | <div> | |
| 269 | + | <dt> | |
| 270 | + | <kbd>j</kbd> | |
| 271 | + | <kbd>k</kbd> | |
| 272 | + | </dt> | |
| 273 | + | <dd>move cursor down / up</dd> | |
| 274 | + | </div> | |
| 275 | + | <div> | |
| 276 | + | <dt> | |
| 277 | + | <kbd>enter</kbd> | |
| 278 | + | </dt> | |
| 279 | + | <dd>open row under cursor</dd> | |
| 280 | + | </div> | |
| 281 | + | <div> | |
| 282 | + | <dt> | |
| 283 | + | <kbd>g</kbd> | |
| 284 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 285 | + | </dt> | |
| 286 | + | <dd>first / last row</dd> | |
| 287 | + | </div> | |
| 288 | + | <div> | |
| 289 | + | <dt> | |
| 290 | + | <kbd>/</kbd> | |
| 291 | + | </dt> | |
| 292 | + | <dd>focus the filter</dd> | |
| 293 | + | </div> | |
| 294 | + | <div> | |
| 295 | + | <dt> | |
| 296 | + | <kbd>g h</kbd> | |
| 297 | + | <kbd>g t</kbd> | |
| 298 | + | </dt> | |
| 299 | + | <dd>go home / tree</dd> | |
| 300 | + | </div> | |
| 301 | + | <div> | |
| 302 | + | <dt> | |
| 303 | + | <kbd>g c</kbd> | |
| 304 | + | <kbd>g r</kbd> | |
| 305 | + | </dt> | |
| 306 | + | <dd>go commits / refs</dd> | |
| 307 | + | </div> | |
| 308 | + | <div> | |
| 309 | + | <dt> | |
| 310 | + | <kbd>b</kbd> | |
| 311 | + | </dt> | |
| 312 | + | <dd>switch ref (branches, tags)</dd> | |
| 313 | + | </div> | |
| 314 | + | <div> | |
| 315 | + | <dt> | |
| 316 | + | <kbd>:</kbd> | |
| 317 | + | <kbd>f</kbd> | |
| 318 | + | </dt> | |
| 319 | + | <dd>go to: section, ref, file, repo</dd> | |
| 320 | + | </div> | |
| 321 | + | <div> | |
| 322 | + | <dt> | |
| 323 | + | <kbd>y</kbd> | |
| 324 | + | </dt> | |
| 325 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 326 | + | </div> | |
| 327 | + | <div> | |
| 328 | + | <dt> | |
| 329 | + | <kbd>s</kbd> | |
| 330 | + | <kbd>t</kbd> | |
| 331 | + | <kbd>c</kbd> | |
| 332 | + | <kbd>r</kbd> | |
| 333 | + | </dt> | |
| 334 | + | <dd>repo sections (when shown)</dd> | |
| 335 | + | </div> | |
| 336 | + | <div> | |
| 337 | + | <dt> | |
| 338 | + | <kbd>d</kbd> | |
| 339 | + | <kbd>u</kbd> | |
| 340 | + | <kbd>x</kbd> | |
| 341 | + | </dt> | |
| 342 | + | <dd>commit: split / unified / collapse all</dd> | |
| 343 | + | </div> | |
| 344 | + | <div> | |
| 345 | + | <dt> | |
| 346 | + | <kbd>esc</kbd> | |
| 347 | + | </dt> | |
| 348 | + | <dd>close panel</dd> | |
| 349 | + | </div> | |
| 350 | + | </dl> | |
| 351 | + | </dialog> | |
| 352 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 353 | + | <div class="panel-head"> | |
| 354 | + | <label for="refs-filter">ref></label> | |
| 355 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |
| 356 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 357 | + | </div> | |
| 358 | + | <div class="panel-cols" data-picklist> | |
| 359 | + | <section aria-label="Branches"> | |
| 360 | + | <h2>branches <span class="mute">2</span> | |
| 361 | + | </h2> | |
| 362 | + | <ul> | |
| 363 | + | <li> | |
| 364 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 365 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 366 | + | </li> | |
| 367 | + | <li> | |
| 368 | + | <a href="/diffs/tree/side"> | |
| 369 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 370 | + | </li> | |
| 371 | + | </ul> | |
| 372 | + | </section> | |
| 373 | + | <section aria-label="Tags"> | |
| 374 | + | <h2>tags <span class="mute">1</span> | |
| 375 | + | </h2> | |
| 376 | + | <ul> | |
| 377 | + | <li> | |
| 378 | + | <a href="/diffs/tree/v1.0.0"> | |
| 379 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 380 | + | </li> | |
| 381 | + | </ul> | |
| 382 | + | </section> | |
| 383 | + | </div> | |
| 384 | + | </dialog> | |
| 385 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 386 | + | <div class="panel-head"> | |
| 387 | + | <label for="cmd-q" class="hi">:</label> | |
| 388 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |
| 389 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 390 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |
| 391 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 392 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 393 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |
| 394 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 395 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 396 | + | </div> | |
| 397 | + | <ul id="cmd-list" class="cmd-list"> | |
| 398 | + | <li data-k="go home" data-cursor> | |
| 399 | + | <a href="/" tabindex="-1"> | |
| 400 | + | <span class="k">go</span>home</a> | |
| 401 | + | </li> | |
| 402 | + | <li data-k="section summary"> | |
| 403 | + | <a href="/diffs" tabindex="-1"> | |
| 404 | + | <span class="k">section</span>summary</a> | |
| 405 | + | </li> | |
| 406 | + | <li data-k="section tree"> | |
| 407 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 408 | + | <span class="k">section</span>tree</a> | |
| 409 | + | </li> | |
| 410 | + | <li data-k="section commits"> | |
| 411 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 412 | + | <span class="k">section</span>commits</a> | |
| 413 | + | </li> | |
| 414 | + | <li data-k="section refs"> | |
| 415 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 416 | + | <span class="k">section</span>refs</a> | |
| 417 | + | </li> | |
| 418 | + | <li data-k="branch main"> | |
| 419 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 420 | + | <span class="k">branch</span>main</a> | |
| 421 | + | </li> | |
| 422 | + | <li data-k="branch side"> | |
| 423 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 424 | + | <span class="k">branch</span>side</a> | |
| 425 | + | </li> | |
| 426 | + | <li data-k="tag v1.0.0"> | |
| 427 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 428 | + | <span class="k">tag</span>v1.0.0</a> | |
| 429 | + | </li> | |
| 430 | + | <li data-k="repo long"> | |
| 431 | + | <a href="/long" tabindex="-1"> | |
| 432 | + | <span class="k">repo</span>long</a> | |
| 433 | + | </li> | |
| 434 | + | </ul> | |
| 435 | + | </dialog> | |
| 436 | + | <footer class="bar status" aria-label="Status"> | |
| 437 | + | <span class="mode">commits</span> | |
| 438 | + | <span class="st-ctx">main · 8 commits</span> | |
| 439 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |
| 440 | + | <span class="hi">:</span> go to…</button> | |
| 441 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 442 | + | </span> | |
| 443 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |
| 444 | + | <span class="st-src"> | |
| 445 | + | <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> | |
| 447 | + | </span> | |
| 448 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">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> | |
| 450 | + | </footer> | |
| 451 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 452 | + | if (q('dialog[open]').count) return; | |
| 453 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 454 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 455 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 456 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 457 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 458 | + | })(1); | |
| 459 | + | k-k from:body -> ((d) => { | |
| 460 | + | if (q('dialog[open]').count) return; | |
| 461 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 462 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 463 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 464 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 465 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 466 | + | })(-1); | |
| 467 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 468 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |
| 469 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 470 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 471 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 472 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |
| 473 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 474 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 475 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 476 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 477 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 478 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 479 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 480 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 481 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 482 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 483 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 484 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 485 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 486 | + | k-permalink from:body -> { | |
| 487 | + | let b = document.body.dataset; | |
| 488 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 489 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 490 | + | navigator.clipboard?.writeText(location.href); | |
| 491 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 492 | + | }"> | |
| 493 | + | </div> | |
| 494 | + | </body> | |
| 495 | + | </html> | |
| @@ -0,0 +1,495 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <meta name="color-scheme" content="light dark"> | |||
| 7 | + | <title>commits · diffs · example.test</title> | |||
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 10 | + | </script> | |||
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 12 | + | </script> | |||
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 14 | + | </script> | |||
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 16 | + | </script> | |||
| 17 | + | </head> | |||
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |||
| 19 | + | <a class="skip" href="#main">skip to content</a> | |||
| 20 | + | <header class="bar top"> | |||
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |||
| 22 | + | <span class="fade" aria-hidden="true"> | |||
| 23 | + | </span> | |||
| 24 | + | <nav class="crumbs" aria-label="Path"> | |||
| 25 | + | <ol> | |||
| 26 | + | <li> | |||
| 27 | + | <a href="/diffs">diffs</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/diffs/commits/main">commits</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/diffs/commits/main" aria-current="page">main</a> | |||
| 34 | + | </li> | |||
| 35 | + | </ol> | |||
| 36 | + | </nav> | |||
| 37 | + | <nav class="secs" aria-label="Sections"> | |||
| 38 | + | <ul> | |||
| 39 | + | <li> | |||
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 41 | + | </li> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |||
| 50 | + | </li> | |||
| 51 | + | </ul> | |||
| 52 | + | </nav> | |||
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |||
| 54 | + | </button> | |||
| 55 | + | </header> | |||
| 56 | + | <div class="work has-rail"> | |||
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 58 | + | <div class="filter"> | |||
| 59 | + | <label for="commit-filter">filter></label> | |||
| 60 | + | <input id="commit-filter" type="search" data-filter placeholder="subject, sha or author ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |||
| 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')"> | |||
| 63 | + | </div> | |||
| 64 | + | <div class="days" id="commits-head"> | |||
| 65 | + | <section aria-label="2026-02-08"> | |||
| 66 | + | <h2 class="rule-h"> | |||
| 67 | + | <span>2026-02-08</span> | |||
| 68 | + | </h2> | |||
| 69 | + | <table class="list commits" aria-label="Commits"> | |||
| 70 | + | <tbody data-rows> | |||
| 71 | + | <tr id="c-SHA7" data-cursor> | |||
| 72 | + | <td class="cur" aria-hidden="true"> | |||
| 73 | + | </td> | |||
| 74 | + | <td class="c-sha mute">SHA7</td> | |||
| 75 | + | <td class="c-subject"> | |||
| 76 | + | <a href="/diffs/commit/SHA40">add a very large file</a> | |||
| 77 | + | <span class="tagged">[main]</span> | |||
| 78 | + | </td> | |||
| 79 | + | <td class="c-who dim">T</td> | |||
| 80 | + | <td class="c-when nowrap dim"> | |||
| 81 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 82 | + | </td> | |||
| 83 | + | </tr> | |||
| 84 | + | </tbody> | |||
| 85 | + | </table> | |||
| 86 | + | </section> | |||
| 87 | + | <section aria-label="2026-02-07"> | |||
| 88 | + | <h2 class="rule-h"> | |||
| 89 | + | <span>2026-02-07</span> | |||
| 90 | + | </h2> | |||
| 91 | + | <table class="list commits" aria-label="Commits"> | |||
| 92 | + | <tbody data-rows> | |||
| 93 | + | <tr id="c-SHA7"> | |||
| 94 | + | <td class="cur" aria-hidden="true"> | |||
| 95 | + | </td> | |||
| 96 | + | <td class="c-sha mute">SHA7</td> | |||
| 97 | + | <td class="c-subject"> | |||
| 98 | + | <a href="/diffs/commit/SHA40">add evil.go</a> | |||
| 99 | + | </td> | |||
| 100 | + | <td class="c-who dim">T</td> | |||
| 101 | + | <td class="c-when nowrap dim"> | |||
| 102 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 103 | + | </td> | |||
| 104 | + | </tr> | |||
| 105 | + | </tbody> | |||
| 106 | + | </table> | |||
| 107 | + | </section> | |||
| 108 | + | <section aria-label="2026-02-06"> | |||
| 109 | + | <h2 class="rule-h"> | |||
| 110 | + | <span>2026-02-06</span> | |||
| 111 | + | </h2> | |||
| 112 | + | <table class="list commits" aria-label="Commits"> | |||
| 113 | + | <tbody data-rows> | |||
| 114 | + | <tr id="c-SHA7"> | |||
| 115 | + | <td class="cur" aria-hidden="true"> | |||
| 116 | + | </td> | |||
| 117 | + | <td class="c-sha mute">SHA7</td> | |||
| 118 | + | <td class="c-subject"> | |||
| 119 | + | <a href="/diffs/commit/SHA40">merge the side branch</a> | |||
| 120 | + | <span class="tagged">(merge)</span> | |||
| 121 | + | </td> | |||
| 122 | + | <td class="c-who dim">T</td> | |||
| 123 | + | <td class="c-when nowrap dim"> | |||
| 124 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 125 | + | </td> | |||
| 126 | + | </tr> | |||
| 127 | + | </tbody> | |||
| 128 | + | </table> | |||
| 129 | + | </section> | |||
| 130 | + | <section aria-label="2026-02-05"> | |||
| 131 | + | <h2 class="rule-h"> | |||
| 132 | + | <span>2026-02-05</span> | |||
| 133 | + | </h2> | |||
| 134 | + | <table class="list commits" aria-label="Commits"> | |||
| 135 | + | <tbody data-rows> | |||
| 136 | + | <tr id="c-SHA7"> | |||
| 137 | + | <td class="cur" aria-hidden="true"> | |||
| 138 | + | </td> | |||
| 139 | + | <td class="c-sha mute">SHA7</td> | |||
| 140 | + | <td class="c-subject"> | |||
| 141 | + | <a href="/diffs/commit/SHA40">work on the side</a> | |||
| 142 | + | <span class="tagged">[side]</span> | |||
| 143 | + | </td> | |||
| 144 | + | <td class="c-who dim">T</td> | |||
| 145 | + | <td class="c-when nowrap dim"> | |||
| 146 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 147 | + | </td> | |||
| 148 | + | </tr> | |||
| 149 | + | </tbody> | |||
| 150 | + | </table> | |||
| 151 | + | </section> | |||
| 152 | + | <section aria-label="2026-02-04"> | |||
| 153 | + | <h2 class="rule-h"> | |||
| 154 | + | <span>2026-02-04</span> | |||
| 155 | + | </h2> | |||
| 156 | + | <table class="list commits" aria-label="Commits"> | |||
| 157 | + | <tbody data-rows> | |||
| 158 | + | <tr id="c-SHA7"> | |||
| 159 | + | <td class="cur" aria-hidden="true"> | |||
| 160 | + | </td> | |||
| 161 | + | <td class="c-sha mute">SHA7</td> | |||
| 162 | + | <td class="c-subject"> | |||
| 163 | + | <a href="/diffs/commit/SHA40">change a binary file</a> | |||
| 164 | + | </td> | |||
| 165 | + | <td class="c-who dim">T</td> | |||
| 166 | + | <td class="c-when nowrap dim"> | |||
| 167 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 168 | + | </td> | |||
| 169 | + | </tr> | |||
| 170 | + | </tbody> | |||
| 171 | + | </table> | |||
| 172 | + | </section> | |||
| 173 | + | <section aria-label="2026-02-03"> | |||
| 174 | + | <h2 class="rule-h"> | |||
| 175 | + | <span>2026-02-03</span> | |||
| 176 | + | </h2> | |||
| 177 | + | <table class="list commits" aria-label="Commits"> | |||
| 178 | + | <tbody data-rows> | |||
| 179 | + | <tr id="c-SHA7"> | |||
| 180 | + | <td class="cur" aria-hidden="true"> | |||
| 181 | + | </td> | |||
| 182 | + | <td class="c-sha mute">SHA7</td> | |||
| 183 | + | <td class="c-subject"> | |||
| 184 | + | <a href="/diffs/commit/SHA40">rename a file and delete another</a> | |||
| 185 | + | </td> | |||
| 186 | + | <td class="c-who dim">T</td> | |||
| 187 | + | <td class="c-when nowrap dim"> | |||
| 188 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 189 | + | </td> | |||
| 190 | + | </tr> | |||
| 191 | + | </tbody> | |||
| 192 | + | </table> | |||
| 193 | + | </section> | |||
| 194 | + | <section aria-label="2026-02-02"> | |||
| 195 | + | <h2 class="rule-h"> | |||
| 196 | + | <span>2026-02-02</span> | |||
| 197 | + | </h2> | |||
| 198 | + | <table class="list commits" aria-label="Commits"> | |||
| 199 | + | <tbody data-rows> | |||
| 200 | + | <tr id="c-SHA7"> | |||
| 201 | + | <td class="cur" aria-hidden="true"> | |||
| 202 | + | </td> | |||
| 203 | + | <td class="c-sha mute">SHA7</td> | |||
| 204 | + | <td class="c-subject"> | |||
| 205 | + | <a href="/diffs/commit/SHA40">modify a file and make a script executable</a> | |||
| 206 | + | <span class="tagged">[tag: v1.0.0]</span> | |||
| 207 | + | </td> | |||
| 208 | + | <td class="c-who dim">T</td> | |||
| 209 | + | <td class="c-when nowrap dim"> | |||
| 210 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 211 | + | </td> | |||
| 212 | + | </tr> | |||
| 213 | + | </tbody> | |||
| 214 | + | </table> | |||
| 215 | + | </section> | |||
| 216 | + | <section aria-label="2026-02-01"> | |||
| 217 | + | <h2 class="rule-h"> | |||
| 218 | + | <span>2026-02-01</span> | |||
| 219 | + | </h2> | |||
| 220 | + | <table class="list commits" aria-label="Commits"> | |||
| 221 | + | <tbody data-rows> | |||
| 222 | + | <tr id="c-SHA7"> | |||
| 223 | + | <td class="cur" aria-hidden="true"> | |||
| 224 | + | </td> | |||
| 225 | + | <td class="c-sha mute">SHA7</td> | |||
| 226 | + | <td class="c-subject"> | |||
| 227 | + | <a href="/diffs/commit/SHA40">root: the first files</a> | |||
| 228 | + | </td> | |||
| 229 | + | <td class="c-who dim">T</td> | |||
| 230 | + | <td class="c-when nowrap dim"> | |||
| 231 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 232 | + | </td> | |||
| 233 | + | </tr> | |||
| 234 | + | </tbody> | |||
| 235 | + | </table> | |||
| 236 | + | </section> | |||
| 237 | + | </div> | |||
| 238 | + | </main> | |||
| 239 | + | <aside class="rail" aria-label="Context"> | |||
| 240 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 241 | + | <summary>history · refs on this page</summary> | |||
| 242 | + | <section aria-labelledby="r-hist"> | |||
| 243 | + | <h2 id="r-hist">history</h2> | |||
| 244 | + | <dl class="kv"> | |||
| 245 | + | <dt>ref</dt> | |||
| 246 | + | <dd>main</dd> | |||
| 247 | + | <dt>on page</dt> | |||
| 248 | + | <dd>8</dd> | |||
| 249 | + | </dl> | |||
| 250 | + | </section> | |||
| 251 | + | <section aria-labelledby="r-refs"> | |||
| 252 | + | <h2 id="r-refs">refs on this page</h2> | |||
| 253 | + | <nav class="outline" aria-label="Refs on this page"> | |||
| 254 | + | <a href="#c-SHA7">branch main</a> | |||
| 255 | + | <a href="#c-SHA7">branch side</a> | |||
| 256 | + | <a href="#c-SHA7">tag v1.0.0</a> | |||
| 257 | + | </nav> | |||
| 258 | + | </section> | |||
| 259 | + | </details> | |||
| 260 | + | </aside> | |||
| 261 | + | </div> | |||
| 262 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 263 | + | <div class="panel-head"> | |||
| 264 | + | <span>keys</span> | |||
| 265 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 266 | + | </div> | |||
| 267 | + | <dl class="keys"> | |||
| 268 | + | <div> | |||
| 269 | + | <dt> | |||
| 270 | + | <kbd>j</kbd> | |||
| 271 | + | <kbd>k</kbd> | |||
| 272 | + | </dt> | |||
| 273 | + | <dd>move cursor down / up</dd> | |||
| 274 | + | </div> | |||
| 275 | + | <div> | |||
| 276 | + | <dt> | |||
| 277 | + | <kbd>enter</kbd> | |||
| 278 | + | </dt> | |||
| 279 | + | <dd>open row under cursor</dd> | |||
| 280 | + | </div> | |||
| 281 | + | <div> | |||
| 282 | + | <dt> | |||
| 283 | + | <kbd>g</kbd> | |||
| 284 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 285 | + | </dt> | |||
| 286 | + | <dd>first / last row</dd> | |||
| 287 | + | </div> | |||
| 288 | + | <div> | |||
| 289 | + | <dt> | |||
| 290 | + | <kbd>/</kbd> | |||
| 291 | + | </dt> | |||
| 292 | + | <dd>focus the filter</dd> | |||
| 293 | + | </div> | |||
| 294 | + | <div> | |||
| 295 | + | <dt> | |||
| 296 | + | <kbd>g h</kbd> | |||
| 297 | + | <kbd>g t</kbd> | |||
| 298 | + | </dt> | |||
| 299 | + | <dd>go home / tree</dd> | |||
| 300 | + | </div> | |||
| 301 | + | <div> | |||
| 302 | + | <dt> | |||
| 303 | + | <kbd>g c</kbd> | |||
| 304 | + | <kbd>g r</kbd> | |||
| 305 | + | </dt> | |||
| 306 | + | <dd>go commits / refs</dd> | |||
| 307 | + | </div> | |||
| 308 | + | <div> | |||
| 309 | + | <dt> | |||
| 310 | + | <kbd>b</kbd> | |||
| 311 | + | </dt> | |||
| 312 | + | <dd>switch ref (branches, tags)</dd> | |||
| 313 | + | </div> | |||
| 314 | + | <div> | |||
| 315 | + | <dt> | |||
| 316 | + | <kbd>:</kbd> | |||
| 317 | + | <kbd>f</kbd> | |||
| 318 | + | </dt> | |||
| 319 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 320 | + | </div> | |||
| 321 | + | <div> | |||
| 322 | + | <dt> | |||
| 323 | + | <kbd>y</kbd> | |||
| 324 | + | </dt> | |||
| 325 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 326 | + | </div> | |||
| 327 | + | <div> | |||
| 328 | + | <dt> | |||
| 329 | + | <kbd>s</kbd> | |||
| 330 | + | <kbd>t</kbd> | |||
| 331 | + | <kbd>c</kbd> | |||
| 332 | + | <kbd>r</kbd> | |||
| 333 | + | </dt> | |||
| 334 | + | <dd>repo sections (when shown)</dd> | |||
| 335 | + | </div> | |||
| 336 | + | <div> | |||
| 337 | + | <dt> | |||
| 338 | + | <kbd>d</kbd> | |||
| 339 | + | <kbd>u</kbd> | |||
| 340 | + | <kbd>x</kbd> | |||
| 341 | + | </dt> | |||
| 342 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 343 | + | </div> | |||
| 344 | + | <div> | |||
| 345 | + | <dt> | |||
| 346 | + | <kbd>esc</kbd> | |||
| 347 | + | </dt> | |||
| 348 | + | <dd>close panel</dd> | |||
| 349 | + | </div> | |||
| 350 | + | </dl> | |||
| 351 | + | </dialog> | |||
| 352 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 353 | + | <div class="panel-head"> | |||
| 354 | + | <label for="refs-filter">ref></label> | |||
| 355 | + | <input id="refs-filter" type="search" placeholder="filter branches and tags" autocomplete="off" data-pick hx-on:input="let t = this.value.toLowerCase(); q('#panel-refs li').arr().forEach(li => li.hidden = !li.textContent.toLowerCase().includes(t))"> | |||
| 356 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 357 | + | </div> | |||
| 358 | + | <div class="panel-cols" data-picklist> | |||
| 359 | + | <section aria-label="Branches"> | |||
| 360 | + | <h2>branches <span class="mute">2</span> | |||
| 361 | + | </h2> | |||
| 362 | + | <ul> | |||
| 363 | + | <li> | |||
| 364 | + | <a href="/diffs/tree/main" aria-current="true"> | |||
| 365 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 366 | + | </li> | |||
| 367 | + | <li> | |||
| 368 | + | <a href="/diffs/tree/side"> | |||
| 369 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |||
| 370 | + | </li> | |||
| 371 | + | </ul> | |||
| 372 | + | </section> | |||
| 373 | + | <section aria-label="Tags"> | |||
| 374 | + | <h2>tags <span class="mute">1</span> | |||
| 375 | + | </h2> | |||
| 376 | + | <ul> | |||
| 377 | + | <li> | |||
| 378 | + | <a href="/diffs/tree/v1.0.0"> | |||
| 379 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |||
| 380 | + | </li> | |||
| 381 | + | </ul> | |||
| 382 | + | </section> | |||
| 383 | + | </div> | |||
| 384 | + | </dialog> | |||
| 385 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 386 | + | <div class="panel-head"> | |||
| 387 | + | <label for="cmd-q" class="hi">:</label> | |||
| 388 | + | <input id="cmd-q" type="search" placeholder="section, ref, file or repo" autocomplete="off" spellcheck="false" aria-controls="cmd-list" hx-on:input="let t = this.value.toLowerCase().trim(); | |||
| 389 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 390 | + | let first = q('#cmd-list > li:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#cmd-list > li')" hx-on:keydown="let vis = q('#cmd-list > li:not([hidden])').arr(); | |||
| 391 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 392 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 393 | + | if (step) { event.preventDefault(); let n = vis[Math.max(0, Math.min(vis.length - 1, i + step))]; n && (q(n).take('data-cursor', '#cmd-list > li'), n.scrollIntoView({ block: 'nearest' })) } | |||
| 394 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 395 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 396 | + | </div> | |||
| 397 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 398 | + | <li data-k="go home" data-cursor> | |||
| 399 | + | <a href="/" tabindex="-1"> | |||
| 400 | + | <span class="k">go</span>home</a> | |||
| 401 | + | </li> | |||
| 402 | + | <li data-k="section summary"> | |||
| 403 | + | <a href="/diffs" tabindex="-1"> | |||
| 404 | + | <span class="k">section</span>summary</a> | |||
| 405 | + | </li> | |||
| 406 | + | <li data-k="section tree"> | |||
| 407 | + | <a href="/diffs/tree/main" tabindex="-1"> | |||
| 408 | + | <span class="k">section</span>tree</a> | |||
| 409 | + | </li> | |||
| 410 | + | <li data-k="section commits"> | |||
| 411 | + | <a href="/diffs/commits/main" tabindex="-1"> | |||
| 412 | + | <span class="k">section</span>commits</a> | |||
| 413 | + | </li> | |||
| 414 | + | <li data-k="section refs"> | |||
| 415 | + | <a href="/diffs/refs" tabindex="-1"> | |||
| 416 | + | <span class="k">section</span>refs</a> | |||
| 417 | + | </li> | |||
| 418 | + | <li data-k="branch main"> | |||
| 419 | + | <a href="/diffs/tree/main" tabindex="-1"> | |||
| 420 | + | <span class="k">branch</span>main</a> | |||
| 421 | + | </li> | |||
| 422 | + | <li data-k="branch side"> | |||
| 423 | + | <a href="/diffs/tree/side" tabindex="-1"> | |||
| 424 | + | <span class="k">branch</span>side</a> | |||
| 425 | + | </li> | |||
| 426 | + | <li data-k="tag v1.0.0"> | |||
| 427 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |||
| 428 | + | <span class="k">tag</span>v1.0.0</a> | |||
| 429 | + | </li> | |||
| 430 | + | <li data-k="repo long"> | |||
| 431 | + | <a href="/long" tabindex="-1"> | |||
| 432 | + | <span class="k">repo</span>long</a> | |||
| 433 | + | </li> | |||
| 434 | + | </ul> | |||
| 435 | + | </dialog> | |||
| 436 | + | <footer class="bar status" aria-label="Status"> | |||
| 437 | + | <span class="mode">commits</span> | |||
| 438 | + | <span class="st-ctx">main · 8 commits</span> | |||
| 439 | + | <button type="button" class="st-cmd" aria-haspopup="dialog" aria-controls="panel-cmd" aria-keyshortcuts=":" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-cmd'))"> | |||
| 440 | + | <span class="hi">:</span> go to…</button> | |||
| 441 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 442 | + | </span> | |||
| 443 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |||
| 444 | + | <span class="st-src"> | |||
| 445 | + | <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> | |||
| 447 | + | </span> | |||
| 448 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">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> | |||
| 450 | + | </footer> | |||
| 451 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 452 | + | if (q('dialog[open]').count) return; | |||
| 453 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 454 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 455 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 456 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 457 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 458 | + | })(1); | |||
| 459 | + | k-k from:body -> ((d) => { | |||
| 460 | + | if (q('dialog[open]').count) return; | |||
| 461 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 462 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 463 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 464 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 465 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 466 | + | })(-1); | |||
| 467 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 468 | + | k-bottom from:body -> q('[data-rows] > :not([hidden])').arr().at(-1)?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 1e9), q('#main').arr()[0].scrollTo(0, 1e9)); | |||
| 469 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 470 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 471 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 472 | + | k-refs from:body -> q('#panel-refs').count && (q('dialog[open]').arr().forEach(d => d.close()), q('#panel-refs').arr()[0].show(), q('#refs-filter').arr()[0].focus()); | |||
| 473 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 474 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 475 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 476 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 477 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 478 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 479 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 480 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 481 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 482 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 483 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 484 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 485 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 486 | + | k-permalink from:body -> { | |||
| 487 | + | let b = document.body.dataset; | |||
| 488 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 489 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 490 | + | navigator.clipboard?.writeText(location.href); | |||
| 491 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 492 | + | }"> | |||
| 493 | + | </div> | |||
| 494 | + | </body> | |||
| 495 | + | </html> | |||
A cmd/ermite/web_commits_test.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "regexp" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/require" | |
| 16 | + | ||
| 17 | + | "bnema.dev/ermite/internal/app" | |
| 18 | + | "bnema.dev/ermite/internal/domain" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // diffFixture is the repository of the commit pages: one commit for each | |
| 22 | + | // kind of change a diff shows. | |
| 23 | + | type diffFixture struct { | |
| 24 | + | root, modify, rename, binary, merge, trojan, big string | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // createRepo creates a public repository for the pages. | |
| 28 | + | func (h *harness) createRepo(name string, vis domain.Visibility) { | |
| 29 | + | h.t.Helper() | |
| 30 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Description: "Diffs", Visibility: vis}) | |
| 31 | + | require.NoError(h.t, err) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // commitAt commits what is staged at a fixed date and returns the id. | |
| 35 | + | func (h *harness) commitAt(wt, date, msg string) string { | |
| 36 | + | h.t.Helper() | |
| 37 | + | env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date} | |
| 38 | + | out, err := h.gitEnv(env, wt, "commit", "-q", "--allow-empty", "-m", msg) | |
| 39 | + | require.NoError(h.t, err, out) | |
| 40 | + | return strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (h *harness) put(wt, name string, data []byte, mode os.FileMode) { | |
| 44 | + | h.t.Helper() | |
| 45 | + | full := filepath.Join(wt, name) | |
| 46 | + | require.NoError(h.t, os.MkdirAll(filepath.Dir(full), 0o755)) | |
| 47 | + | require.NoError(h.t, os.WriteFile(full, data, mode)) | |
| 48 | + | require.NoError(h.t, os.Chmod(full, mode)) | |
| 49 | + | h.mustGit(wt, "add", name) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // diffRepo builds the fixture and pushes it as a public repository. | |
| 53 | + | func (h *harness) diffRepo(name string) diffFixture { | |
| 54 | + | h.t.Helper() | |
| 55 | + | wt := h.t.TempDir() | |
| 56 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| 57 | + | var f diffFixture | |
| 58 | + | ||
| 59 | + | h.put(wt, "a.txt", []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644) | |
| 60 | + | h.put(wt, "old.txt", []byte("keep me\nkeep me too\nand me\n"), 0o644) | |
| 61 | + | h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o644) | |
| 62 | + | h.put(wt, "gone.txt", []byte("bye\n"), 0o644) | |
| 63 | + | h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}, 0o644) | |
| 64 | + | f.root = h.commitAt(wt, "2026-02-01T10:00:00Z", "root: the first files") | |
| 65 | + | ||
| 66 | + | h.put(wt, "a.txt", []byte("one\n2\nthree\nfour\nfive\nsix\n"), 0o644) | |
| 67 | + | h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o755) | |
| 68 | + | f.modify = h.commitAt(wt, "2026-02-02T10:00:00Z", "modify a file and make a script executable\n\nThe body has a second paragraph.\n\nAnd <b>html</b> & \"quotes\".") | |
| 69 | + | ||
| 70 | + | h.mustGit(wt, "mv", "old.txt", "docs-old.txt") | |
| 71 | + | h.mustGit(wt, "rm", "-q", "gone.txt") | |
| 72 | + | f.rename = h.commitAt(wt, "2026-02-03T10:00:00Z", "rename a file and delete another") | |
| 73 | + | ||
| 74 | + | h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 9, 9, 9, 9}, 0o644) | |
| 75 | + | f.binary = h.commitAt(wt, "2026-02-04T10:00:00Z", "change a binary file") | |
| 76 | + | ||
| 77 | + | // A side branch from the root, merged with a merge commit. | |
| 78 | + | h.mustGit(wt, "checkout", "-q", "-b", "side", f.root) | |
| 79 | + | h.put(wt, "side.txt", []byte("from the side\n"), 0o644) | |
| 80 | + | h.commitAt(wt, "2026-02-05T10:00:00Z", "work on the side") | |
| 81 | + | h.mustGit(wt, "checkout", "-q", "main") | |
| 82 | + | env := []string{"GIT_AUTHOR_DATE=2026-02-06T10:00:00Z", "GIT_COMMITTER_DATE=2026-02-06T10:00:00Z"} | |
| 83 | + | out, err := h.gitEnv(env, wt, "merge", "-q", "--no-ff", "-m", "merge the side branch", "side") | |
| 84 | + | require.NoError(h.t, err, out) | |
| 85 | + | f.merge = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 86 | + | ||
| 87 | + | h.put(wt, "evil.go", []byte("package evil\n\nfunc admin(user string) bool {\n\tif user == \"user\u202e \u2066// check\u2069 \u2066\" {\n\t\treturn true // z\u200bw\n\t}\n\treturn false\n}\n"), 0o644) | |
| 88 | + | f.trojan = h.commitAt(wt, "2026-02-07T10:00:00Z", "add evil.go\n\nlooks \u202efine\u202c") | |
| 89 | + | ||
| 90 | + | var big strings.Builder | |
| 91 | + | for i := range 2600 { | |
| 92 | + | fmt.Fprintf(&big, "line %d\n", i) | |
| 93 | + | } | |
| 94 | + | h.put(wt, "big.txt", []byte(big.String()), 0o644) | |
| 95 | + | f.big = h.commitAt(wt, "2026-02-08T10:00:00Z", "add a very large file") | |
| 96 | + | ||
| 97 | + | h.mustGit(wt, "tag", "v1.0.0", f.modify) | |
| 98 | + | h.createRepo(name, domain.VisibilityPublic) | |
| 99 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "side", "--tags") | |
| 100 | + | return f | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // longRepo is a repository with more commits than a page lists: 52 on main, | |
| 104 | + | // one of which adds a.txt and one changes it. | |
| 105 | + | func (h *harness) longRepo(name string) { | |
| 106 | + | h.t.Helper() | |
| 107 | + | wt := h.t.TempDir() | |
| 108 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| 109 | + | base := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |
| 110 | + | for i := range 52 { | |
| 111 | + | switch i { | |
| 112 | + | case 3: | |
| 113 | + | h.put(wt, "a.txt", []byte("first\n"), 0o644) | |
| 114 | + | case 30: | |
| 115 | + | h.put(wt, "a.txt", []byte("second\n"), 0o644) | |
| 116 | + | default: | |
| 117 | + | h.put(wt, "counter.txt", []byte(fmt.Sprintf("%d\n", i)), 0o644) | |
| 118 | + | } | |
| 119 | + | h.commitAt(wt, base.Add(time.Duration(i)*12*time.Hour).Format(time.RFC3339), fmt.Sprintf("commit number %d", i)) | |
| 120 | + | } | |
| 121 | + | h.createRepo(name, domain.VisibilityPublic) | |
| 122 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | var reOlder = regexp.MustCompile(`<a href="([^"]+)" rel="next">`) | |
| 126 | + | ||
| 127 | + | // unescape undoes the HTML escaping of an attribute value. | |
| 128 | + | func unescape(s string) string { return strings.ReplaceAll(s, "&", "&") } | |
| 129 | + | ||
| 130 | + | // AC1: the history pages render for the fixtures with the structure of the | |
| 131 | + | // mockup (golden HTML, normalised). | |
| 132 | + | func TestWebCommitsGolden(t *testing.T) { | |
| 133 | + | h := newHarness(t, harnessOpts{}) | |
| 134 | + | h.diffRepo("diffs") | |
| 135 | + | h.longRepo("long") | |
| 136 | + | ||
| 137 | + | // The second page is reached by the link of the first, as a visitor would. | |
| 138 | + | _, _, first := h.get("/long/commits/main") | |
| 139 | + | m := reOlder.FindStringSubmatch(first) | |
| 140 | + | require.Len(t, m, 2, "the first page links to the older commits") | |
| 141 | + | second := unescape(m[1]) | |
| 142 | + | assert.True(t, strings.HasPrefix(second, "/long/commits/main?page="), second) | |
| 143 | + | ||
| 144 | + | for _, tt := range []struct{ name, path string }{ | |
| 145 | + | {"commits-root", "/long/commits/main"}, | |
| 146 | + | {"commits-page2", second}, | |
| 147 | + | {"commits-path", "/long/commits/main/a.txt"}, | |
| 148 | + | {"commits-small", "/diffs/commits"}, | |
| 149 | + | } { | |
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |
| 151 | + | status, hdr, body := h.get(tt.path) | |
| 152 | + | require.Equal(t, 200, status) | |
| 153 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |
| 154 | + | assertNoInline(t, body) | |
| 155 | + | golden(t, tt.name, normalise(body)) | |
| 156 | + | }) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // The behaviours of the history pages, asserted on their own. | |
| 161 | + | func TestWebCommits(t *testing.T) { | |
| 162 | + | h := newHarness(t, harnessOpts{}) | |
| 163 | + | f := h.diffRepo("diffs") | |
| 164 | + | h.longRepo("long") | |
| 165 | + | get := func(path string, status int) string { | |
| 166 | + | t.Helper() | |
| 167 | + | st, _, body := h.get(path) | |
| 168 | + | require.Equal(t, status, st, path) | |
| 169 | + | return body | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | t.Run("history", func(t *testing.T) { | |
| 173 | + | first := get("/long/commits/main", 200) | |
| 174 | + | assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits") | |
| 175 | + | assert.Contains(t, first, "commit number 51") | |
| 176 | + | assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page") | |
| 177 | + | assert.Contains(t, first, "<span class=\"mute\">← newest</span>") | |
| 178 | + | second := unescape(reOlder.FindStringSubmatch(first)[1]) | |
| 179 | + | page2 := get(second, 200) | |
| 180 | + | assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`)) | |
| 181 | + | assert.Contains(t, page2, `href="/long/commits/main">← newest</a>`) | |
| 182 | + | assert.Contains(t, page2, "<span class=\"mute\">older →</span>") | |
| 183 | + | get("/long/commits/main?page=garbage", 404) | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | t.Run("path filter", func(t *testing.T) { | |
| 187 | + | body := get("/long/commits/main/a.txt", 200) | |
| 188 | + | assert.Equal(t, 2, strings.Count(body, `class="c-sha mute"`)) | |
| 189 | + | assert.Contains(t, body, "history of <code>a.txt</code> at main") | |
| 190 | + | assert.Contains(t, body, `href="/long/blob/main/a.txt"`, "the way to the file") | |
| 191 | + | get("/long/commits/main/never/existed.txt", 404) | |
| 192 | + | }) | |
| 193 | + | ||
| 194 | + | t.Run("refs", func(t *testing.T) { | |
| 195 | + | // A tag, a branch, a commit id, and a ref with a slash. | |
| 196 | + | assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file") | |
| 197 | + | assert.Contains(t, get("/diffs/commits/side", 200), "work on the side") | |
| 198 | + | assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file") | |
| 199 | + | assert.Contains(t, get("/diffs/commits/main", 200), `[tag: v1.0.0]`) | |
| 200 | + | assert.Contains(t, get("/diffs/commits/main", 200), `(merge)`) | |
| 201 | + | get("/diffs/commits/nope", 404) | |
| 202 | + | get("/diffs/commits/mai", 404) | |
| 203 | + | get("/nope/commits/main", 404) | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // AC2: the history pages of a private repository are the 404 page of a | |
| 208 | + | // repository that does not exist. | |
| 209 | + | func TestWebCommitsPrivate(t *testing.T) { | |
| 210 | + | h := newHarness(t, harnessOpts{}) | |
| 211 | + | wt := h.workTree("main", "secret.txt") | |
| 212 | + | h.createRepo("hidden-one", domain.VisibilityPrivate) | |
| 213 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main") | |
| 214 | + | ||
| 215 | + | _, _, missing := h.get("/never-existed") | |
| 216 | + | for _, p := range []string{"/hidden-one/commits/main", "/hidden-one/commits", "/hidden-one/commits/main/secret.txt"} { | |
| 217 | + | st, hdr, body := h.get(p) | |
| 218 | + | assert.Equal(t, 404, st, p) | |
| 219 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |
| 220 | + | assert.NotContains(t, body, "hidden-one", p) | |
| 221 | + | assert.NotContains(t, body, "secret.txt", p) | |
| 222 | + | assert.Equal(t, normalise(missing), normalise(body), p) | |
| 223 | + | } | |
| 224 | + | } | |
| @@ -0,0 +1,224 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "os" | |||
| 8 | + | "path/filepath" | |||
| 9 | + | "regexp" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/stretchr/testify/assert" | |||
| 15 | + | "github.com/stretchr/testify/require" | |||
| 16 | + | ||||
| 17 | + | "bnema.dev/ermite/internal/app" | |||
| 18 | + | "bnema.dev/ermite/internal/domain" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // diffFixture is the repository of the commit pages: one commit for each | |||
| 22 | + | // kind of change a diff shows. | |||
| 23 | + | type diffFixture struct { | |||
| 24 | + | root, modify, rename, binary, merge, trojan, big string | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // createRepo creates a public repository for the pages. | |||
| 28 | + | func (h *harness) createRepo(name string, vis domain.Visibility) { | |||
| 29 | + | h.t.Helper() | |||
| 30 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name(name), Description: "Diffs", Visibility: vis}) | |||
| 31 | + | require.NoError(h.t, err) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // commitAt commits what is staged at a fixed date and returns the id. | |||
| 35 | + | func (h *harness) commitAt(wt, date, msg string) string { | |||
| 36 | + | h.t.Helper() | |||
| 37 | + | env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date} | |||
| 38 | + | out, err := h.gitEnv(env, wt, "commit", "-q", "--allow-empty", "-m", msg) | |||
| 39 | + | require.NoError(h.t, err, out) | |||
| 40 | + | return strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (h *harness) put(wt, name string, data []byte, mode os.FileMode) { | |||
| 44 | + | h.t.Helper() | |||
| 45 | + | full := filepath.Join(wt, name) | |||
| 46 | + | require.NoError(h.t, os.MkdirAll(filepath.Dir(full), 0o755)) | |||
| 47 | + | require.NoError(h.t, os.WriteFile(full, data, mode)) | |||
| 48 | + | require.NoError(h.t, os.Chmod(full, mode)) | |||
| 49 | + | h.mustGit(wt, "add", name) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | // diffRepo builds the fixture and pushes it as a public repository. | |||
| 53 | + | func (h *harness) diffRepo(name string) diffFixture { | |||
| 54 | + | h.t.Helper() | |||
| 55 | + | wt := h.t.TempDir() | |||
| 56 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |||
| 57 | + | var f diffFixture | |||
| 58 | + | ||||
| 59 | + | h.put(wt, "a.txt", []byte("one\ntwo\nthree\nfour\nfive\n"), 0o644) | |||
| 60 | + | h.put(wt, "old.txt", []byte("keep me\nkeep me too\nand me\n"), 0o644) | |||
| 61 | + | h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o644) | |||
| 62 | + | h.put(wt, "gone.txt", []byte("bye\n"), 0o644) | |||
| 63 | + | h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 1, 2, 3}, 0o644) | |||
| 64 | + | f.root = h.commitAt(wt, "2026-02-01T10:00:00Z", "root: the first files") | |||
| 65 | + | ||||
| 66 | + | h.put(wt, "a.txt", []byte("one\n2\nthree\nfour\nfive\nsix\n"), 0o644) | |||
| 67 | + | h.put(wt, "run.sh", []byte("#!/bin/sh\necho hi\n"), 0o755) | |||
| 68 | + | f.modify = h.commitAt(wt, "2026-02-02T10:00:00Z", "modify a file and make a script executable\n\nThe body has a second paragraph.\n\nAnd <b>html</b> & \"quotes\".") | |||
| 69 | + | ||||
| 70 | + | h.mustGit(wt, "mv", "old.txt", "docs-old.txt") | |||
| 71 | + | h.mustGit(wt, "rm", "-q", "gone.txt") | |||
| 72 | + | f.rename = h.commitAt(wt, "2026-02-03T10:00:00Z", "rename a file and delete another") | |||
| 73 | + | ||||
| 74 | + | h.put(wt, "logo.bin", []byte{0x89, 'P', 'N', 'G', 0, 9, 9, 9, 9}, 0o644) | |||
| 75 | + | f.binary = h.commitAt(wt, "2026-02-04T10:00:00Z", "change a binary file") | |||
| 76 | + | ||||
| 77 | + | // A side branch from the root, merged with a merge commit. | |||
| 78 | + | h.mustGit(wt, "checkout", "-q", "-b", "side", f.root) | |||
| 79 | + | h.put(wt, "side.txt", []byte("from the side\n"), 0o644) | |||
| 80 | + | h.commitAt(wt, "2026-02-05T10:00:00Z", "work on the side") | |||
| 81 | + | h.mustGit(wt, "checkout", "-q", "main") | |||
| 82 | + | env := []string{"GIT_AUTHOR_DATE=2026-02-06T10:00:00Z", "GIT_COMMITTER_DATE=2026-02-06T10:00:00Z"} | |||
| 83 | + | out, err := h.gitEnv(env, wt, "merge", "-q", "--no-ff", "-m", "merge the side branch", "side") | |||
| 84 | + | require.NoError(h.t, err, out) | |||
| 85 | + | f.merge = strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |||
| 86 | + | ||||
| 87 | + | h.put(wt, "evil.go", []byte("package evil\n\nfunc admin(user string) bool {\n\tif user == \"user\u202e \u2066// check\u2069 \u2066\" {\n\t\treturn true // z\u200bw\n\t}\n\treturn false\n}\n"), 0o644) | |||
| 88 | + | f.trojan = h.commitAt(wt, "2026-02-07T10:00:00Z", "add evil.go\n\nlooks \u202efine\u202c") | |||
| 89 | + | ||||
| 90 | + | var big strings.Builder | |||
| 91 | + | for i := range 2600 { | |||
| 92 | + | fmt.Fprintf(&big, "line %d\n", i) | |||
| 93 | + | } | |||
| 94 | + | h.put(wt, "big.txt", []byte(big.String()), 0o644) | |||
| 95 | + | f.big = h.commitAt(wt, "2026-02-08T10:00:00Z", "add a very large file") | |||
| 96 | + | ||||
| 97 | + | h.mustGit(wt, "tag", "v1.0.0", f.modify) | |||
| 98 | + | h.createRepo(name, domain.VisibilityPublic) | |||
| 99 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "side", "--tags") | |||
| 100 | + | return f | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // longRepo is a repository with more commits than a page lists: 52 on main, | |||
| 104 | + | // one of which adds a.txt and one changes it. | |||
| 105 | + | func (h *harness) longRepo(name string) { | |||
| 106 | + | h.t.Helper() | |||
| 107 | + | wt := h.t.TempDir() | |||
| 108 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |||
| 109 | + | base := time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC) | |||
| 110 | + | for i := range 52 { | |||
| 111 | + | switch i { | |||
| 112 | + | case 3: | |||
| 113 | + | h.put(wt, "a.txt", []byte("first\n"), 0o644) | |||
| 114 | + | case 30: | |||
| 115 | + | h.put(wt, "a.txt", []byte("second\n"), 0o644) | |||
| 116 | + | default: | |||
| 117 | + | h.put(wt, "counter.txt", []byte(fmt.Sprintf("%d\n", i)), 0o644) | |||
| 118 | + | } | |||
| 119 | + | h.commitAt(wt, base.Add(time.Duration(i)*12*time.Hour).Format(time.RFC3339), fmt.Sprintf("commit number %d", i)) | |||
| 120 | + | } | |||
| 121 | + | h.createRepo(name, domain.VisibilityPublic) | |||
| 122 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | var reOlder = regexp.MustCompile(`<a href="([^"]+)" rel="next">`) | |||
| 126 | + | ||||
| 127 | + | // unescape undoes the HTML escaping of an attribute value. | |||
| 128 | + | func unescape(s string) string { return strings.ReplaceAll(s, "&", "&") } | |||
| 129 | + | ||||
| 130 | + | // AC1: the history pages render for the fixtures with the structure of the | |||
| 131 | + | // mockup (golden HTML, normalised). | |||
| 132 | + | func TestWebCommitsGolden(t *testing.T) { | |||
| 133 | + | h := newHarness(t, harnessOpts{}) | |||
| 134 | + | h.diffRepo("diffs") | |||
| 135 | + | h.longRepo("long") | |||
| 136 | + | ||||
| 137 | + | // The second page is reached by the link of the first, as a visitor would. | |||
| 138 | + | _, _, first := h.get("/long/commits/main") | |||
| 139 | + | m := reOlder.FindStringSubmatch(first) | |||
| 140 | + | require.Len(t, m, 2, "the first page links to the older commits") | |||
| 141 | + | second := unescape(m[1]) | |||
| 142 | + | assert.True(t, strings.HasPrefix(second, "/long/commits/main?page="), second) | |||
| 143 | + | ||||
| 144 | + | for _, tt := range []struct{ name, path string }{ | |||
| 145 | + | {"commits-root", "/long/commits/main"}, | |||
| 146 | + | {"commits-page2", second}, | |||
| 147 | + | {"commits-path", "/long/commits/main/a.txt"}, | |||
| 148 | + | {"commits-small", "/diffs/commits"}, | |||
| 149 | + | } { | |||
| 150 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 151 | + | status, hdr, body := h.get(tt.path) | |||
| 152 | + | require.Equal(t, 200, status) | |||
| 153 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |||
| 154 | + | assertNoInline(t, body) | |||
| 155 | + | golden(t, tt.name, normalise(body)) | |||
| 156 | + | }) | |||
| 157 | + | } | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | // The behaviours of the history pages, asserted on their own. | |||
| 161 | + | func TestWebCommits(t *testing.T) { | |||
| 162 | + | h := newHarness(t, harnessOpts{}) | |||
| 163 | + | f := h.diffRepo("diffs") | |||
| 164 | + | h.longRepo("long") | |||
| 165 | + | get := func(path string, status int) string { | |||
| 166 | + | t.Helper() | |||
| 167 | + | st, _, body := h.get(path) | |||
| 168 | + | require.Equal(t, status, st, path) | |||
| 169 | + | return body | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | t.Run("history", func(t *testing.T) { | |||
| 173 | + | first := get("/long/commits/main", 200) | |||
| 174 | + | assert.Equal(t, 50, strings.Count(first, `class="c-sha mute"`), "a page lists 50 commits") | |||
| 175 | + | assert.Contains(t, first, "commit number 51") | |||
| 176 | + | assert.NotContains(t, first, "commit number 1<", "the oldest are on the next page") | |||
| 177 | + | assert.Contains(t, first, "<span class=\"mute\">← newest</span>") | |||
| 178 | + | second := unescape(reOlder.FindStringSubmatch(first)[1]) | |||
| 179 | + | page2 := get(second, 200) | |||
| 180 | + | assert.Equal(t, 2, strings.Count(page2, `class="c-sha mute"`)) | |||
| 181 | + | assert.Contains(t, page2, `href="/long/commits/main">← newest</a>`) | |||
| 182 | + | assert.Contains(t, page2, "<span class=\"mute\">older →</span>") | |||
| 183 | + | get("/long/commits/main?page=garbage", 404) | |||
| 184 | + | }) | |||
| 185 | + | ||||
| 186 | + | t.Run("path filter", func(t *testing.T) { | |||
| 187 | + | body := get("/long/commits/main/a.txt", 200) | |||
| 188 | + | assert.Equal(t, 2, strings.Count(body, `class="c-sha mute"`)) | |||
| 189 | + | assert.Contains(t, body, "history of <code>a.txt</code> at main") | |||
| 190 | + | assert.Contains(t, body, `href="/long/blob/main/a.txt"`, "the way to the file") | |||
| 191 | + | get("/long/commits/main/never/existed.txt", 404) | |||
| 192 | + | }) | |||
| 193 | + | ||||
| 194 | + | t.Run("refs", func(t *testing.T) { | |||
| 195 | + | // A tag, a branch, a commit id, and a ref with a slash. | |||
| 196 | + | assert.Contains(t, get("/diffs/commits/v1.0.0", 200), "modify a file") | |||
| 197 | + | assert.Contains(t, get("/diffs/commits/side", 200), "work on the side") | |||
| 198 | + | assert.Contains(t, get("/diffs/commits/"+f.modify[:10], 200), "modify a file") | |||
| 199 | + | assert.Contains(t, get("/diffs/commits/main", 200), `[tag: v1.0.0]`) | |||
| 200 | + | assert.Contains(t, get("/diffs/commits/main", 200), `(merge)`) | |||
| 201 | + | get("/diffs/commits/nope", 404) | |||
| 202 | + | get("/diffs/commits/mai", 404) | |||
| 203 | + | get("/nope/commits/main", 404) | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | // AC2: the history pages of a private repository are the 404 page of a | |||
| 208 | + | // repository that does not exist. | |||
| 209 | + | func TestWebCommitsPrivate(t *testing.T) { | |||
| 210 | + | h := newHarness(t, harnessOpts{}) | |||
| 211 | + | wt := h.workTree("main", "secret.txt") | |||
| 212 | + | h.createRepo("hidden-one", domain.VisibilityPrivate) | |||
| 213 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main") | |||
| 214 | + | ||||
| 215 | + | _, _, missing := h.get("/never-existed") | |||
| 216 | + | for _, p := range []string{"/hidden-one/commits/main", "/hidden-one/commits", "/hidden-one/commits/main/secret.txt"} { | |||
| 217 | + | st, hdr, body := h.get(p) | |||
| 218 | + | assert.Equal(t, 404, st, p) | |||
| 219 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |||
| 220 | + | assert.NotContains(t, body, "hidden-one", p) | |||
| 221 | + | assert.NotContains(t, body, "secret.txt", p) | |||
| 222 | + | assert.Equal(t, normalise(missing), normalise(body), p) | |||
| 223 | + | } | |||
| 224 | + | } | |||
A internal/adapters/web/commits.go +238 -0
| @@ -0,0 +1,238 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "net/url" | |
| 9 | + | "path" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | ||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // commitsPerPage is how many commits one page of history lists. | |
| 19 | + | const commitsPerPage = 50 | |
| 20 | + | ||
| 21 | + | // DayGroup is the commits of one day (UTC, by committer date). | |
| 22 | + | type DayGroup struct { | |
| 23 | + | Day string | |
| 24 | + | Rows []CommitRow | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | // CommitsPage is the history of a ref, or of a path at a ref. | |
| 28 | + | type CommitsPage struct { | |
| 29 | + | Ref string | |
| 30 | + | // Path is the path the history is limited to, ready to display; "" for | |
| 31 | + | // the whole history. | |
| 32 | + | Path string | |
| 33 | + | Days []DayGroup | |
| 34 | + | Count int | |
| 35 | + | // Older is the link to the next page, "" on the last page. Newest is the | |
| 36 | + | // link back to the first page, "" on the first page: page tokens only go | |
| 37 | + | // forward. | |
| 38 | + | Older, Newest string | |
| 39 | + | // Open links to the file or directory at this ref, when it exists there. | |
| 40 | + | Open *Link | |
| 41 | + | // Refs are the branch heads and tags among the commits of this page. | |
| 42 | + | Refs []Link | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // revSel is what the URL of a history page selects. | |
| 46 | + | type revSel struct { | |
| 47 | + | rev string // what the API is asked for: a commit id | |
| 48 | + | name string // what the page shows | |
| 49 | + | path string // the path limit, "" for none | |
| 50 | + | // detached is true when a commit id, not a ref, was named. | |
| 51 | + | detached bool | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // isCommitID reports whether s can be a commit id or an abbreviation of one: | |
| 55 | + | // 7 to 40 lowercase hexadecimal digits. | |
| 56 | + | func isCommitID(s string) bool { | |
| 57 | + | if len(s) < 7 || len(s) > 40 { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | for _, c := range []byte(s) { | |
| 61 | + | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return true | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // selectRev reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | |
| 69 | + | // longest prefix that names a branch or a tag; an empty rest selects the head. | |
| 70 | + | func (d *repoData) selectRev(rest string) (revSel, bool) { | |
| 71 | + | rest = strings.Trim(rest, "/") | |
| 72 | + | if rest == "" { | |
| 73 | + | if d.head == nil { | |
| 74 | + | return revSel{}, false | |
| 75 | + | } | |
| 76 | + | return revSel{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | |
| 77 | + | } | |
| 78 | + | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |
| 79 | + | if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" { | |
| 80 | + | return revSel{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | |
| 81 | + | } | |
| 82 | + | first, p, _ := strings.Cut(rest, "/") | |
| 83 | + | if isCommitID(first) { | |
| 84 | + | return revSel{rev: first, name: first, path: p, detached: true}, true | |
| 85 | + | } | |
| 86 | + | return revSel{}, false | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // commits lists the history of a ref, newest first, a page at a time, with | |
| 90 | + | // an optional path to limit it to. The page token of the API travels in the | |
| 91 | + | // "page" query parameter. | |
| 92 | + | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 93 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 94 | + | if err != nil { | |
| 95 | + | return nil, 0, err | |
| 96 | + | } | |
| 97 | + | sel, ok := d.selectRev(r.PathValue("rest")) | |
| 98 | + | if !ok { | |
| 99 | + | return nil, 0, errNotFound | |
| 100 | + | } | |
| 101 | + | token := r.URL.Query().Get("page") | |
| 102 | + | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |
| 103 | + | Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token, | |
| 104 | + | })) | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, 0, err | |
| 107 | + | } | |
| 108 | + | cs := res.Msg.GetCommits() | |
| 109 | + | if len(cs) == 0 && token == "" && sel.path != "" { | |
| 110 | + | // Git answers an empty history for a path that never existed. | |
| 111 | + | return nil, 0, errNotFound | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | |
| 115 | + | base := repoHref(d.name(), "commits", sel.name, sel.path) | |
| 116 | + | if t := res.Msg.GetNextPageToken(); t != "" { | |
| 117 | + | p.Older = base + "?page=" + url.QueryEscape(t) | |
| 118 | + | } | |
| 119 | + | if token != "" { | |
| 120 | + | p.Newest = base | |
| 121 | + | } | |
| 122 | + | s.fillDays(&p, d, cs) | |
| 123 | + | if sel.path != "" && token == "" { | |
| 124 | + | p.Open = s.pathLink(ctx, d, sel) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | l := s.repoLayout(ctx, d, "commits") | |
| 128 | + | s.setRef(&l, sel) | |
| 129 | + | l.Crumbs = commitsCrumbs(d, sel) | |
| 130 | + | if sel.path == "" { | |
| 131 | + | l.Title = "commits · " + d.name() + " · " + s.instance | |
| 132 | + | } else { | |
| 133 | + | l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance | |
| 134 | + | } | |
| 135 | + | what := plural(len(cs), "commit", "commits") | |
| 136 | + | if p.Older != "" { | |
| 137 | + | what += " · more" | |
| 138 | + | } | |
| 139 | + | if p.Path != "" { | |
| 140 | + | what = "history of " + p.Path + " · " + what | |
| 141 | + | } | |
| 142 | + | l.Status = Status{ | |
| 143 | + | Mode: "commits", Context: sel.name + " · " + what, | |
| 144 | + | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(cs), | |
| 145 | + | } | |
| 146 | + | l.HasRail, l.RailSum = true, "history · refs on this page" | |
| 147 | + | return Commits(l, p), http.StatusOK, nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // setRef points the ref switcher of the layout at what the page shows. | |
| 151 | + | func (s *Server) setRef(l *Layout, sel revSel) { | |
| 152 | + | if sel.detached { | |
| 153 | + | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(sel.rev), "" | |
| 154 | + | if len(sel.rev) == 40 { | |
| 155 | + | l.SHA = sel.rev | |
| 156 | + | } | |
| 157 | + | return | |
| 158 | + | } | |
| 159 | + | l.Ref, l.RefPath, l.SHA, l.Short = sel.name, sel.name, sel.rev, short(sel.rev) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // commitsCrumbs is the path in the top bar: repo, commits, ref, then the | |
| 163 | + | // directories of the path, each of which links to its tree. | |
| 164 | + | func commitsCrumbs(d *repoData, sel revSel) []Crumb { | |
| 165 | + | home := repoHref(d.name(), "commits", sel.name) | |
| 166 | + | cs := []Crumb{ | |
| 167 | + | {Label: d.name(), Href: repoHref(d.name())}, | |
| 168 | + | {Label: "commits", Href: home}, | |
| 169 | + | {Label: sel.name, Href: home, Current: sel.path == ""}, | |
| 170 | + | } | |
| 171 | + | if sel.path == "" { | |
| 172 | + | return cs | |
| 173 | + | } | |
| 174 | + | segs := strings.Split(sel.path, "/") | |
| 175 | + | for i, seg := range segs { | |
| 176 | + | last := i == len(segs)-1 | |
| 177 | + | c := Crumb{Label: visibleText(seg), Current: last} | |
| 178 | + | if !last { | |
| 179 | + | c.Href = repoHref(d.name(), "tree", sel.name, strings.Join(segs[:i+1], "/")) | |
| 180 | + | } | |
| 181 | + | cs = append(cs, c) | |
| 182 | + | } | |
| 183 | + | return cs | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // fillDays groups the commits of a page by day and lists the refs among them. | |
| 187 | + | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) { | |
| 188 | + | now := s.cfg.Now() | |
| 189 | + | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |
| 190 | + | for i, c := range cs { | |
| 191 | + | row := CommitRow{ | |
| 192 | + | ID: "c-" + short(c.GetId()), Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), | |
| 193 | + | CHref: repoHref(d.name(), "commit", c.GetId()), | |
| 194 | + | Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1, | |
| 195 | + | Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0, | |
| 196 | + | } | |
| 197 | + | day := "unknown date" | |
| 198 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |
| 199 | + | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |
| 200 | + | day = t.AsTime().UTC().Format("2006-01-02") | |
| 201 | + | } | |
| 202 | + | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | |
| 203 | + | p.Days = append(p.Days, DayGroup{Day: day}) | |
| 204 | + | } | |
| 205 | + | g := &p.Days[len(p.Days)-1] | |
| 206 | + | g.Rows = append(g.Rows, row) | |
| 207 | + | for _, t := range row.Tags { | |
| 208 | + | p.Refs = append(p.Refs, Link{Label: "tag " + t, Href: "#" + row.ID}) | |
| 209 | + | } | |
| 210 | + | for _, b := range row.Branches { | |
| 211 | + | p.Refs = append(p.Refs, Link{Label: "branch " + b, Href: "#" + row.ID}) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // pathLink finds what the path of a history is at the ref: a directory | |
| 217 | + | // (tree) or a file (blob). It is nil when the path is not there, a deleted | |
| 218 | + | // file for one, or when the listing fails: the link is a convenience. | |
| 219 | + | func (s *Server) pathLink(ctx context.Context, d *repoData, sel revSel) *Link { | |
| 220 | + | dir := path.Dir(sel.path) | |
| 221 | + | if dir == "." { | |
| 222 | + | dir = "" | |
| 223 | + | } | |
| 224 | + | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir})) | |
| 225 | + | if err != nil { | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | for _, e := range tree.Msg.GetEntries() { | |
| 229 | + | if e.GetPath() != sel.path { | |
| 230 | + | continue | |
| 231 | + | } | |
| 232 | + | if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY { | |
| 233 | + | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | |
| 234 | + | } | |
| 235 | + | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | |
| 236 | + | } | |
| 237 | + | return nil | |
| 238 | + | } | |
| @@ -0,0 +1,238 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "net/url" | |||
| 9 | + | "path" | |||
| 10 | + | "strings" | |||
| 11 | + | ||||
| 12 | + | "connectrpc.com/connect" | |||
| 13 | + | "github.com/a-h/templ" | |||
| 14 | + | ||||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // commitsPerPage is how many commits one page of history lists. | |||
| 19 | + | const commitsPerPage = 50 | |||
| 20 | + | ||||
| 21 | + | // DayGroup is the commits of one day (UTC, by committer date). | |||
| 22 | + | type DayGroup struct { | |||
| 23 | + | Day string | |||
| 24 | + | Rows []CommitRow | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | // CommitsPage is the history of a ref, or of a path at a ref. | |||
| 28 | + | type CommitsPage struct { | |||
| 29 | + | Ref string | |||
| 30 | + | // Path is the path the history is limited to, ready to display; "" for | |||
| 31 | + | // the whole history. | |||
| 32 | + | Path string | |||
| 33 | + | Days []DayGroup | |||
| 34 | + | Count int | |||
| 35 | + | // Older is the link to the next page, "" on the last page. Newest is the | |||
| 36 | + | // link back to the first page, "" on the first page: page tokens only go | |||
| 37 | + | // forward. | |||
| 38 | + | Older, Newest string | |||
| 39 | + | // Open links to the file or directory at this ref, when it exists there. | |||
| 40 | + | Open *Link | |||
| 41 | + | // Refs are the branch heads and tags among the commits of this page. | |||
| 42 | + | Refs []Link | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // revSel is what the URL of a history page selects. | |||
| 46 | + | type revSel struct { | |||
| 47 | + | rev string // what the API is asked for: a commit id | |||
| 48 | + | name string // what the page shows | |||
| 49 | + | path string // the path limit, "" for none | |||
| 50 | + | // detached is true when a commit id, not a ref, was named. | |||
| 51 | + | detached bool | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // isCommitID reports whether s can be a commit id or an abbreviation of one: | |||
| 55 | + | // 7 to 40 lowercase hexadecimal digits. | |||
| 56 | + | func isCommitID(s string) bool { | |||
| 57 | + | if len(s) < 7 || len(s) > 40 { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | for _, c := range []byte(s) { | |||
| 61 | + | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | return true | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // selectRev reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | |||
| 69 | + | // longest prefix that names a branch or a tag; an empty rest selects the head. | |||
| 70 | + | func (d *repoData) selectRev(rest string) (revSel, bool) { | |||
| 71 | + | rest = strings.Trim(rest, "/") | |||
| 72 | + | if rest == "" { | |||
| 73 | + | if d.head == nil { | |||
| 74 | + | return revSel{}, false | |||
| 75 | + | } | |||
| 76 | + | return revSel{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | |||
| 77 | + | } | |||
| 78 | + | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |||
| 79 | + | if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" { | |||
| 80 | + | return revSel{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | |||
| 81 | + | } | |||
| 82 | + | first, p, _ := strings.Cut(rest, "/") | |||
| 83 | + | if isCommitID(first) { | |||
| 84 | + | return revSel{rev: first, name: first, path: p, detached: true}, true | |||
| 85 | + | } | |||
| 86 | + | return revSel{}, false | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // commits lists the history of a ref, newest first, a page at a time, with | |||
| 90 | + | // an optional path to limit it to. The page token of the API travels in the | |||
| 91 | + | // "page" query parameter. | |||
| 92 | + | func (s *Server) commits(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 93 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 94 | + | if err != nil { | |||
| 95 | + | return nil, 0, err | |||
| 96 | + | } | |||
| 97 | + | sel, ok := d.selectRev(r.PathValue("rest")) | |||
| 98 | + | if !ok { | |||
| 99 | + | return nil, 0, errNotFound | |||
| 100 | + | } | |||
| 101 | + | token := r.URL.Query().Get("page") | |||
| 102 | + | res, err := s.cfg.Git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{ | |||
| 103 | + | Repo: d.name(), Revision: sel.rev, Path: sel.path, PageSize: commitsPerPage, PageToken: token, | |||
| 104 | + | })) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return nil, 0, err | |||
| 107 | + | } | |||
| 108 | + | cs := res.Msg.GetCommits() | |||
| 109 | + | if len(cs) == 0 && token == "" && sel.path != "" { | |||
| 110 | + | // Git answers an empty history for a path that never existed. | |||
| 111 | + | return nil, 0, errNotFound | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | p := CommitsPage{Ref: sel.name, Path: visibleText(sel.path), Count: len(cs)} | |||
| 115 | + | base := repoHref(d.name(), "commits", sel.name, sel.path) | |||
| 116 | + | if t := res.Msg.GetNextPageToken(); t != "" { | |||
| 117 | + | p.Older = base + "?page=" + url.QueryEscape(t) | |||
| 118 | + | } | |||
| 119 | + | if token != "" { | |||
| 120 | + | p.Newest = base | |||
| 121 | + | } | |||
| 122 | + | s.fillDays(&p, d, cs) | |||
| 123 | + | if sel.path != "" && token == "" { | |||
| 124 | + | p.Open = s.pathLink(ctx, d, sel) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | l := s.repoLayout(ctx, d, "commits") | |||
| 128 | + | s.setRef(&l, sel) | |||
| 129 | + | l.Crumbs = commitsCrumbs(d, sel) | |||
| 130 | + | if sel.path == "" { | |||
| 131 | + | l.Title = "commits · " + d.name() + " · " + s.instance | |||
| 132 | + | } else { | |||
| 133 | + | l.Title = "history of " + visibleText(sel.path) + " · " + d.name() + " · " + s.instance | |||
| 134 | + | } | |||
| 135 | + | what := plural(len(cs), "commit", "commits") | |||
| 136 | + | if p.Older != "" { | |||
| 137 | + | what += " · more" | |||
| 138 | + | } | |||
| 139 | + | if p.Path != "" { | |||
| 140 | + | what = "history of " + p.Path + " · " + what | |||
| 141 | + | } | |||
| 142 | + | l.Status = Status{ | |||
| 143 | + | Mode: "commits", Context: sel.name + " · " + what, | |||
| 144 | + | Hints: "j/k move · enter open · / filter · ? help", Cursor: true, Count: len(cs), | |||
| 145 | + | } | |||
| 146 | + | l.HasRail, l.RailSum = true, "history · refs on this page" | |||
| 147 | + | return Commits(l, p), http.StatusOK, nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // setRef points the ref switcher of the layout at what the page shows. | |||
| 151 | + | func (s *Server) setRef(l *Layout, sel revSel) { | |||
| 152 | + | if sel.detached { | |||
| 153 | + | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(sel.rev), "" | |||
| 154 | + | if len(sel.rev) == 40 { | |||
| 155 | + | l.SHA = sel.rev | |||
| 156 | + | } | |||
| 157 | + | return | |||
| 158 | + | } | |||
| 159 | + | l.Ref, l.RefPath, l.SHA, l.Short = sel.name, sel.name, sel.rev, short(sel.rev) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // commitsCrumbs is the path in the top bar: repo, commits, ref, then the | |||
| 163 | + | // directories of the path, each of which links to its tree. | |||
| 164 | + | func commitsCrumbs(d *repoData, sel revSel) []Crumb { | |||
| 165 | + | home := repoHref(d.name(), "commits", sel.name) | |||
| 166 | + | cs := []Crumb{ | |||
| 167 | + | {Label: d.name(), Href: repoHref(d.name())}, | |||
| 168 | + | {Label: "commits", Href: home}, | |||
| 169 | + | {Label: sel.name, Href: home, Current: sel.path == ""}, | |||
| 170 | + | } | |||
| 171 | + | if sel.path == "" { | |||
| 172 | + | return cs | |||
| 173 | + | } | |||
| 174 | + | segs := strings.Split(sel.path, "/") | |||
| 175 | + | for i, seg := range segs { | |||
| 176 | + | last := i == len(segs)-1 | |||
| 177 | + | c := Crumb{Label: visibleText(seg), Current: last} | |||
| 178 | + | if !last { | |||
| 179 | + | c.Href = repoHref(d.name(), "tree", sel.name, strings.Join(segs[:i+1], "/")) | |||
| 180 | + | } | |||
| 181 | + | cs = append(cs, c) | |||
| 182 | + | } | |||
| 183 | + | return cs | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // fillDays groups the commits of a page by day and lists the refs among them. | |||
| 187 | + | func (s *Server) fillDays(p *CommitsPage, d *repoData, cs []*pb.Commit) { | |||
| 188 | + | now := s.cfg.Now() | |||
| 189 | + | branchesAt, tagsAt := namesAt(d.branches), namesAt(d.tags) | |||
| 190 | + | for i, c := range cs { | |||
| 191 | + | row := CommitRow{ | |||
| 192 | + | ID: "c-" + short(c.GetId()), Short: short(c.GetId()), Subject: visibleText(c.GetSummary()), | |||
| 193 | + | CHref: repoHref(d.name(), "commit", c.GetId()), | |||
| 194 | + | Author: visibleText(c.GetAuthor().GetName()), Merge: len(c.GetParentIds()) > 1, | |||
| 195 | + | Branches: branchesAt[c.GetId()], Tags: tagsAt[c.GetId()], Cursor: i == 0, | |||
| 196 | + | } | |||
| 197 | + | day := "unknown date" | |||
| 198 | + | if t := c.GetCommitter().GetTime(); t.IsValid() { | |||
| 199 | + | row.When, row.WhenAt = ago(now, t.AsTime()), rfc3339(t.AsTime()) | |||
| 200 | + | day = t.AsTime().UTC().Format("2006-01-02") | |||
| 201 | + | } | |||
| 202 | + | if n := len(p.Days); n == 0 || p.Days[n-1].Day != day { | |||
| 203 | + | p.Days = append(p.Days, DayGroup{Day: day}) | |||
| 204 | + | } | |||
| 205 | + | g := &p.Days[len(p.Days)-1] | |||
| 206 | + | g.Rows = append(g.Rows, row) | |||
| 207 | + | for _, t := range row.Tags { | |||
| 208 | + | p.Refs = append(p.Refs, Link{Label: "tag " + t, Href: "#" + row.ID}) | |||
| 209 | + | } | |||
| 210 | + | for _, b := range row.Branches { | |||
| 211 | + | p.Refs = append(p.Refs, Link{Label: "branch " + b, Href: "#" + row.ID}) | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | // pathLink finds what the path of a history is at the ref: a directory | |||
| 217 | + | // (tree) or a file (blob). It is nil when the path is not there, a deleted | |||
| 218 | + | // file for one, or when the listing fails: the link is a convenience. | |||
| 219 | + | func (s *Server) pathLink(ctx context.Context, d *repoData, sel revSel) *Link { | |||
| 220 | + | dir := path.Dir(sel.path) | |||
| 221 | + | if dir == "." { | |||
| 222 | + | dir = "" | |||
| 223 | + | } | |||
| 224 | + | tree, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{Repo: d.name(), Revision: sel.rev, Path: dir})) | |||
| 225 | + | if err != nil { | |||
| 226 | + | return nil | |||
| 227 | + | } | |||
| 228 | + | for _, e := range tree.Msg.GetEntries() { | |||
| 229 | + | if e.GetPath() != sel.path { | |||
| 230 | + | continue | |||
| 231 | + | } | |||
| 232 | + | if e.GetKind() == pb.EntryKind_ENTRY_KIND_DIRECTORY { | |||
| 233 | + | return &Link{Label: "open", Href: repoHref(d.name(), "tree", sel.name, sel.path)} | |||
| 234 | + | } | |||
| 235 | + | return &Link{Label: "open", Href: repoHref(d.name(), "blob", sel.name, sel.path)} | |||
| 236 | + | } | |||
| 237 | + | return nil | |||
| 238 | + | } | |||
A internal/adapters/web/commits.templ +83 -0
| @@ -0,0 +1,83 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Commits is the history of a ref, or of a path at a ref: the commits of the | |
| 6 | + | // page grouped by day, and the way to the older ones. | |
| 7 | + | templ Commits(l Layout, p CommitsPage) { | |
| 8 | + | @Shell(l, commitsRail(p)) { | |
| 9 | + | <div class="filter"> | |
| 10 | + | <label for="commit-filter">filter></label> | |
| 11 | + | <input | |
| 12 | + | id="commit-filter" | |
| 13 | + | type="search" | |
| 14 | + | data-filter | |
| 15 | + | placeholder="subject, sha or author ( / )" | |
| 16 | + | autocomplete="off" | |
| 17 | + | hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 18 | + | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |
| 19 | + | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')" | |
| 20 | + | /> | |
| 21 | + | </div> | |
| 22 | + | if p.Path != "" { | |
| 23 | + | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | |
| 24 | + | } | |
| 25 | + | <div class="days" id="commits-head"> | |
| 26 | + | for _, g := range p.Days { | |
| 27 | + | <section aria-label={ g.Day }> | |
| 28 | + | <h2 class="rule-h"><span>{ g.Day }</span></h2> | |
| 29 | + | @CommitRows(g.Rows) | |
| 30 | + | </section> | |
| 31 | + | } | |
| 32 | + | </div> | |
| 33 | + | if len(p.Days) == 0 { | |
| 34 | + | <p class="lede dim">no commits.</p> | |
| 35 | + | } | |
| 36 | + | if p.Older != "" || p.Newest != "" { | |
| 37 | + | <nav class="pager" aria-label="Pages"> | |
| 38 | + | if p.Newest != "" { | |
| 39 | + | <a href={ templ.SafeURL(p.Newest) }>← newest</a> | |
| 40 | + | } else { | |
| 41 | + | <span class="mute">← newest</span> | |
| 42 | + | } | |
| 43 | + | if p.Older != "" { | |
| 44 | + | <a href={ templ.SafeURL(p.Older) } rel="next">older →</a> | |
| 45 | + | } else { | |
| 46 | + | <span class="mute">older →</span> | |
| 47 | + | } | |
| 48 | + | </nav> | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | templ commitsRail(p CommitsPage) { | |
| 54 | + | <section aria-labelledby="r-hist"> | |
| 55 | + | <h2 id="r-hist">history</h2> | |
| 56 | + | <dl class="kv"> | |
| 57 | + | <dt>ref</dt> | |
| 58 | + | <dd>{ p.Ref }</dd> | |
| 59 | + | <dt>on page</dt> | |
| 60 | + | <dd>{ itoa(p.Count) }</dd> | |
| 61 | + | if p.Path != "" { | |
| 62 | + | <dt>path</dt> | |
| 63 | + | <dd>{ p.Path }</dd> | |
| 64 | + | } | |
| 65 | + | if p.Open != nil { | |
| 66 | + | <dt>file</dt> | |
| 67 | + | <dd><a href={ templ.SafeURL(p.Open.Href) }>{ p.Open.Label }</a></dd> | |
| 68 | + | } | |
| 69 | + | </dl> | |
| 70 | + | </section> | |
| 71 | + | <section aria-labelledby="r-refs"> | |
| 72 | + | <h2 id="r-refs">refs on this page</h2> | |
| 73 | + | if len(p.Refs) > 0 { | |
| 74 | + | <nav class="outline" aria-label="Refs on this page"> | |
| 75 | + | for _, r := range p.Refs { | |
| 76 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Label }</a> | |
| 77 | + | } | |
| 78 | + | </nav> | |
| 79 | + | } else { | |
| 80 | + | <p class="dim">no tag or branch head here</p> | |
| 81 | + | } | |
| 82 | + | </section> | |
| 83 | + | } | |
| @@ -0,0 +1,83 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // Commits is the history of a ref, or of a path at a ref: the commits of the | |||
| 6 | + | // page grouped by day, and the way to the older ones. | |||
| 7 | + | templ Commits(l Layout, p CommitsPage) { | |||
| 8 | + | @Shell(l, commitsRail(p)) { | |||
| 9 | + | <div class="filter"> | |||
| 10 | + | <label for="commit-filter">filter></label> | |||
| 11 | + | <input | |||
| 12 | + | id="commit-filter" | |||
| 13 | + | type="search" | |||
| 14 | + | data-filter | |||
| 15 | + | placeholder="subject, sha or author ( / )" | |||
| 16 | + | autocomplete="off" | |||
| 17 | + | hx-on:input="let t = this.value.toLowerCase(); q('.days tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |||
| 18 | + | q('.days section').arr().forEach(s => s.hidden = !s.querySelector('tr:not([hidden])')); | |||
| 19 | + | let first = q('.days tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '.days tr')" | |||
| 20 | + | /> | |||
| 21 | + | </div> | |||
| 22 | + | if p.Path != "" { | |||
| 23 | + | <p class="lede dim">history of <code>{ p.Path }</code> at { p.Ref }</p> | |||
| 24 | + | } | |||
| 25 | + | <div class="days" id="commits-head"> | |||
| 26 | + | for _, g := range p.Days { | |||
| 27 | + | <section aria-label={ g.Day }> | |||
| 28 | + | <h2 class="rule-h"><span>{ g.Day }</span></h2> | |||
| 29 | + | @CommitRows(g.Rows) | |||
| 30 | + | </section> | |||
| 31 | + | } | |||
| 32 | + | </div> | |||
| 33 | + | if len(p.Days) == 0 { | |||
| 34 | + | <p class="lede dim">no commits.</p> | |||
| 35 | + | } | |||
| 36 | + | if p.Older != "" || p.Newest != "" { | |||
| 37 | + | <nav class="pager" aria-label="Pages"> | |||
| 38 | + | if p.Newest != "" { | |||
| 39 | + | <a href={ templ.SafeURL(p.Newest) }>← newest</a> | |||
| 40 | + | } else { | |||
| 41 | + | <span class="mute">← newest</span> | |||
| 42 | + | } | |||
| 43 | + | if p.Older != "" { | |||
| 44 | + | <a href={ templ.SafeURL(p.Older) } rel="next">older →</a> | |||
| 45 | + | } else { | |||
| 46 | + | <span class="mute">older →</span> | |||
| 47 | + | } | |||
| 48 | + | </nav> | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | templ commitsRail(p CommitsPage) { | |||
| 54 | + | <section aria-labelledby="r-hist"> | |||
| 55 | + | <h2 id="r-hist">history</h2> | |||
| 56 | + | <dl class="kv"> | |||
| 57 | + | <dt>ref</dt> | |||
| 58 | + | <dd>{ p.Ref }</dd> | |||
| 59 | + | <dt>on page</dt> | |||
| 60 | + | <dd>{ itoa(p.Count) }</dd> | |||
| 61 | + | if p.Path != "" { | |||
| 62 | + | <dt>path</dt> | |||
| 63 | + | <dd>{ p.Path }</dd> | |||
| 64 | + | } | |||
| 65 | + | if p.Open != nil { | |||
| 66 | + | <dt>file</dt> | |||
| 67 | + | <dd><a href={ templ.SafeURL(p.Open.Href) }>{ p.Open.Label }</a></dd> | |||
| 68 | + | } | |||
| 69 | + | </dl> | |||
| 70 | + | </section> | |||
| 71 | + | <section aria-labelledby="r-refs"> | |||
| 72 | + | <h2 id="r-refs">refs on this page</h2> | |||
| 73 | + | if len(p.Refs) > 0 { | |||
| 74 | + | <nav class="outline" aria-label="Refs on this page"> | |||
| 75 | + | for _, r := range p.Refs { | |||
| 76 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Label }</a> | |||
| 77 | + | } | |||
| 78 | + | </nav> | |||
| 79 | + | } else { | |||
| 80 | + | <p class="dim">no tag or branch head here</p> | |||
| 81 | + | } | |||
| 82 | + | </section> | |||
| 83 | + | } | |||
A internal/adapters/web/commits_templ.go +371 -0 generated file: collapsed
A internal/adapters/web/commits_test.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestIsCommitID(t *testing.T) { | |
| 14 | + | for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} { | |
| 15 | + | assert.True(t, isCommitID(s), s) | |
| 16 | + | } | |
| 17 | + | for _, s := range []string{"", "abcdef", "ABCDEF0", "main", "abcdefg", "0123456789abcdef0123456789abcdef012345678"} { | |
| 18 | + | assert.False(t, isCommitID(s), s) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestSelectRev(t *testing.T) { | |
| 23 | + | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |
| 24 | + | d := &repoData{branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, tags: []*pb.Ref{ref(tag, "v1")}} | |
| 25 | + | d.head = d.branches[0] | |
| 26 | + | tests := []struct { | |
| 27 | + | in string | |
| 28 | + | want revSel | |
| 29 | + | wantOK bool | |
| 30 | + | describe string | |
| 31 | + | }{ | |
| 32 | + | {"", revSel{rev: "c-main", name: "main"}, true, "head"}, | |
| 33 | + | {"main", revSel{rev: "c-main", name: "main"}, true, "branch"}, | |
| 34 | + | {"main/docs/a.md", revSel{rev: "c-main", name: "main", path: "docs/a.md"}, true, "path"}, | |
| 35 | + | {"feature/x/src", revSel{rev: "c-feature/x", name: "feature/x", path: "src"}, true, "ref with a slash"}, | |
| 36 | + | {"v1", revSel{rev: "c-v1", name: "v1"}, true, "tag"}, | |
| 37 | + | {"abcdef0123/x.go", revSel{rev: "abcdef0123", name: "abcdef0123", path: "x.go", detached: true}, true, "commit id"}, | |
| 38 | + | {"nope", revSel{}, false, "unknown"}, | |
| 39 | + | {"feature", revSel{}, false, "a prefix of a ref is not a ref"}, | |
| 40 | + | } | |
| 41 | + | for _, tt := range tests { | |
| 42 | + | got, ok := d.selectRev(tt.in) | |
| 43 | + | assert.Equal(t, tt.wantOK, ok, tt.describe) | |
| 44 | + | if ok { | |
| 45 | + | assert.Equal(t, tt.want, got, tt.describe) | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | _, ok := (&repoData{}).selectRev("") | |
| 49 | + | assert.False(t, ok, "an empty repository has no history") | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ||||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestIsCommitID(t *testing.T) { | |||
| 14 | + | for _, s := range []string{"abcdef0", "0123456789abcdef0123456789abcdef01234567"} { | |||
| 15 | + | assert.True(t, isCommitID(s), s) | |||
| 16 | + | } | |||
| 17 | + | for _, s := range []string{"", "abcdef", "ABCDEF0", "main", "abcdefg", "0123456789abcdef0123456789abcdef012345678"} { | |||
| 18 | + | assert.False(t, isCommitID(s), s) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestSelectRev(t *testing.T) { | |||
| 23 | + | b, tag := pb.RefKind_REF_KIND_BRANCH, pb.RefKind_REF_KIND_TAG | |||
| 24 | + | d := &repoData{branches: []*pb.Ref{ref(b, "main"), ref(b, "feature/x")}, tags: []*pb.Ref{ref(tag, "v1")}} | |||
| 25 | + | d.head = d.branches[0] | |||
| 26 | + | tests := []struct { | |||
| 27 | + | in string | |||
| 28 | + | want revSel | |||
| 29 | + | wantOK bool | |||
| 30 | + | describe string | |||
| 31 | + | }{ | |||
| 32 | + | {"", revSel{rev: "c-main", name: "main"}, true, "head"}, | |||
| 33 | + | {"main", revSel{rev: "c-main", name: "main"}, true, "branch"}, | |||
| 34 | + | {"main/docs/a.md", revSel{rev: "c-main", name: "main", path: "docs/a.md"}, true, "path"}, | |||
| 35 | + | {"feature/x/src", revSel{rev: "c-feature/x", name: "feature/x", path: "src"}, true, "ref with a slash"}, | |||
| 36 | + | {"v1", revSel{rev: "c-v1", name: "v1"}, true, "tag"}, | |||
| 37 | + | {"abcdef0123/x.go", revSel{rev: "abcdef0123", name: "abcdef0123", path: "x.go", detached: true}, true, "commit id"}, | |||
| 38 | + | {"nope", revSel{}, false, "unknown"}, | |||
| 39 | + | {"feature", revSel{}, false, "a prefix of a ref is not a ref"}, | |||
| 40 | + | } | |||
| 41 | + | for _, tt := range tests { | |||
| 42 | + | got, ok := d.selectRev(tt.in) | |||
| 43 | + | assert.Equal(t, tt.wantOK, ok, tt.describe) | |||
| 44 | + | if ok { | |||
| 45 | + | assert.Equal(t, tt.want, got, tt.describe) | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | _, ok := (&repoData{}).selectRev("") | |||
| 49 | + | assert.False(t, ok, "an empty repository has no history") | |||
| 50 | + | } | |||
A internal/adapters/web/text.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | "unicode" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // invisible reports whether r is a character that changes how code reads | |
| 12 | + | // without being seen: the bidirectional controls and the other format | |
| 13 | + | // characters (Cf: zero-width, joiners, tags, BOM…), the control characters, | |
| 14 | + | // the line and paragraph separators, and the blank Hangul fillers. A tab is | |
| 15 | + | // not one, nor is the line break of a commit message. | |
| 16 | + | func invisible(r rune) bool { | |
| 17 | + | switch r { | |
| 18 | + | case '\t', '\n': | |
| 19 | + | return false | |
| 20 | + | case 0x115F, 0x1160, 0x3164, 0xFFA0: // Hangul fillers render blank | |
| 21 | + | return true | |
| 22 | + | } | |
| 23 | + | return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // marker is the visible form of an invisible character. | |
| 27 | + | func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) } | |
| 28 | + | ||
| 29 | + | // visibleText is s with every invisible character replaced by its marker, | |
| 30 | + | // for the places that show text as a plain string: paths, names, subjects. | |
| 31 | + | func visibleText(s string) string { | |
| 32 | + | if !strings.ContainsFunc(s, invisible) { | |
| 33 | + | return s | |
| 34 | + | } | |
| 35 | + | var b strings.Builder | |
| 36 | + | for _, r := range s { | |
| 37 | + | if invisible(r) { | |
| 38 | + | b.WriteString(marker(r)) | |
| 39 | + | } else { | |
| 40 | + | b.WriteRune(r) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | return b.String() | |
| 44 | + | } | |
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | "unicode" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // invisible reports whether r is a character that changes how code reads | |||
| 12 | + | // without being seen: the bidirectional controls and the other format | |||
| 13 | + | // characters (Cf: zero-width, joiners, tags, BOM…), the control characters, | |||
| 14 | + | // the line and paragraph separators, and the blank Hangul fillers. A tab is | |||
| 15 | + | // not one, nor is the line break of a commit message. | |||
| 16 | + | func invisible(r rune) bool { | |||
| 17 | + | switch r { | |||
| 18 | + | case '\t', '\n': | |||
| 19 | + | return false | |||
| 20 | + | case 0x115F, 0x1160, 0x3164, 0xFFA0: // Hangul fillers render blank | |||
| 21 | + | return true | |||
| 22 | + | } | |||
| 23 | + | return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // marker is the visible form of an invisible character. | |||
| 27 | + | func marker(r rune) string { return fmt.Sprintf("<U+%04X>", r) } | |||
| 28 | + | ||||
| 29 | + | // visibleText is s with every invisible character replaced by its marker, | |||
| 30 | + | // for the places that show text as a plain string: paths, names, subjects. | |||
| 31 | + | func visibleText(s string) string { | |||
| 32 | + | if !strings.ContainsFunc(s, invisible) { | |||
| 33 | + | return s | |||
| 34 | + | } | |||
| 35 | + | var b strings.Builder | |||
| 36 | + | for _, r := range s { | |||
| 37 | + | if invisible(r) { | |||
| 38 | + | b.WriteString(marker(r)) | |||
| 39 | + | } else { | |||
| 40 | + | b.WriteRune(r) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | return b.String() | |||
| 44 | + | } | |||
A internal/adapters/web/text_test.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Invisible characters in names and subjects show as markers. | |
| 12 | + | func TestVisibleText(t *testing.T) { | |
| 13 | + | assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go")) | |
| 14 | + | assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”")) | |
| 15 | + | assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b")) | |
| 16 | + | } | |
| @@ -0,0 +1,16 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // Invisible characters in names and subjects show as markers. | |||
| 12 | + | func TestVisibleText(t *testing.T) { | |||
| 13 | + | assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go")) | |||
| 14 | + | assert.Equal(t, "plain π\t“é”", visibleText("plain π\t“é”")) | |||
| 15 | + | assert.Equal(t, "a<U+0000>b<U+200B>", visibleText("a\x00b\u200b")) | |||
| 16 | + | } | |||
M internal/adapters/web/web.go +2 -0
| @@ -98,6 +98,8 @@ func (s *Server) routes() *http.ServeMux { | |||
| 98 | 98 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 99 | 99 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| 100 | 100 | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | |
| 101 | + | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | |
| 102 | + | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | |
| 101 | 103 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 102 | 104 | return mux | |
| 103 | 105 | } | |
| @@ -98,6 +98,8 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 98 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | 98 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | ||
| 99 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | 99 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | ||
| 100 | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | 100 | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | ||
| 101 | + | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | |||
| 102 | + | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | |||
| 101 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | 103 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | ||
| 102 | return mux | 104 | return mux | ||
| 103 | } | 105 | } | ||