feat(web): add tree pages
GET /<repo>/tree[/<ref>[/<path>]] lists a directory with the last commit of each entry (include_last_commit), notes when the listing or the last commits are truncated, the path in the top bar, and the README of the directory below, rendered with links relative to that directory. The ref is the longest prefix of the path that names a branch or a tag (names may contain a slash), or a commit id (7 to 40 hex digits); anything else, and any missing path, is the 404 page. Tree and blob share target/locate/ pinRef/fileCrumbs. highlight.go gets its invisible-character markers and a time budget here as well. The e2e route test and the not-found golden used /tree/main as the example of a page without a route; they now use a path that has none.
- commit
- 20d1f288647cb680f6c233d378738d262f55ea5f
- author
- brice · · 2026-10-10 13:24 UTC
- committer
- brice · · 2026-10-10 13:25 UTC
- parent
- 06c0b24
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +2357 -48
M cmd/ermite/e2e_test.go +1 -1
| @@ -103,7 +103,7 @@ func TestRoutes(t *testing.T) { | |||
| 103 | 103 | "/pub.git/info/refs", // dumb protocol: no service | |
| 104 | 104 | "/pub.git/info/refs?service=git-bogus", | |
| 105 | 105 | "/pub.git/config", | |
| 106 | - | "/pub/tree/main", // a page of a later phase | |
| 106 | + | "/pub/events/main", // no such page | |
| 107 | 107 | "/-/nothing", | |
| 108 | 108 | "/Bad.git/info/refs?service=git-upload-pack", | |
| 109 | 109 | "/..%2f..%2fetc/info/refs?service=git-upload-pack", | |
| @@ -103,7 +103,7 @@ func TestRoutes(t *testing.T) { | |||||
| 103 | "/pub.git/info/refs", // dumb protocol: no service | 103 | "/pub.git/info/refs", // dumb protocol: no service | ||
| 104 | "/pub.git/info/refs?service=git-bogus", | 104 | "/pub.git/info/refs?service=git-bogus", | ||
| 105 | "/pub.git/config", | 105 | "/pub.git/config", | ||
| 106 | - | "/pub/tree/main", // a page of a later phase | 106 | + | "/pub/events/main", // no such page |
| 107 | "/-/nothing", | 107 | "/-/nothing", | ||
| 108 | "/Bad.git/info/refs?service=git-upload-pack", | 108 | "/Bad.git/info/refs?service=git-upload-pack", | ||
| 109 | "/..%2f..%2fetc/info/refs?service=git-upload-pack", | 109 | "/..%2f..%2fetc/info/refs?service=git-upload-pack", | ||
A cmd/ermite/testdata/web/tree-nested.html +336 -0
| @@ -0,0 +1,336 @@ | |||
| 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>src/lib · files · 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="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">tree</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/tree/main/src">src</a> | |
| 37 | + | </li> | |
| 38 | + | <li> | |
| 39 | + | <a href="/files/tree/main/src/lib" aria-current="page">lib</a> | |
| 40 | + | </li> | |
| 41 | + | </ol> | |
| 42 | + | </nav> | |
| 43 | + | <nav class="secs" aria-label="Sections"> | |
| 44 | + | <ul> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 53 | + | </li> | |
| 54 | + | <li> | |
| 55 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 56 | + | </li> | |
| 57 | + | </ul> | |
| 58 | + | </nav> | |
| 59 | + | <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> | |
| 60 | + | </button> | |
| 61 | + | </header> | |
| 62 | + | <div class="work"> | |
| 63 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 64 | + | <div class="filter"> | |
| 65 | + | <label for="tree-filter">filter></label> | |
| 66 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 67 | + | </div> | |
| 68 | + | <table class="list tree" aria-label="Entries"> | |
| 69 | + | <thead> | |
| 70 | + | <tr> | |
| 71 | + | <th class="cur"> | |
| 72 | + | </th> | |
| 73 | + | <th class="c-kind"> | |
| 74 | + | </th> | |
| 75 | + | <th class="c-name">name</th> | |
| 76 | + | <th class="c-size">size</th> | |
| 77 | + | <th class="c-sub">last commit</th> | |
| 78 | + | <th class="c-who">author</th> | |
| 79 | + | <th class="c-when">date</th> | |
| 80 | + | </tr> | |
| 81 | + | </thead> | |
| 82 | + | <tbody id="tree-rows" data-rows> | |
| 83 | + | <tr data-cursor> | |
| 84 | + | <td class="cur" aria-hidden="true"> | |
| 85 | + | </td> | |
| 86 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 87 | + | <td class="c-name"> | |
| 88 | + | <a href="/files/blob/main/src/lib/util.go">util.go</a> | |
| 89 | + | </td> | |
| 90 | + | <td class="c-size num dim">12 B</td> | |
| 91 | + | <td class="c-sub dim"> | |
| 92 | + | <a href="/files/commit/SHA40">add src/lib/util.go</a> | |
| 93 | + | </td> | |
| 94 | + | <td class="c-who dim">T</td> | |
| 95 | + | <td class="c-when nowrap dim"> | |
| 96 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 97 | + | </td> | |
| 98 | + | </tr> | |
| 99 | + | </tbody> | |
| 100 | + | </table> | |
| 101 | + | </main> | |
| 102 | + | </div> | |
| 103 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 104 | + | <div class="panel-head"> | |
| 105 | + | <span>keys</span> | |
| 106 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 107 | + | </div> | |
| 108 | + | <dl class="keys"> | |
| 109 | + | <div> | |
| 110 | + | <dt> | |
| 111 | + | <kbd>j</kbd> | |
| 112 | + | <kbd>k</kbd> | |
| 113 | + | </dt> | |
| 114 | + | <dd>move cursor down / up</dd> | |
| 115 | + | </div> | |
| 116 | + | <div> | |
| 117 | + | <dt> | |
| 118 | + | <kbd>enter</kbd> | |
| 119 | + | </dt> | |
| 120 | + | <dd>open row under cursor</dd> | |
| 121 | + | </div> | |
| 122 | + | <div> | |
| 123 | + | <dt> | |
| 124 | + | <kbd>g</kbd> | |
| 125 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 126 | + | </dt> | |
| 127 | + | <dd>first / last row</dd> | |
| 128 | + | </div> | |
| 129 | + | <div> | |
| 130 | + | <dt> | |
| 131 | + | <kbd>/</kbd> | |
| 132 | + | </dt> | |
| 133 | + | <dd>focus the filter</dd> | |
| 134 | + | </div> | |
| 135 | + | <div> | |
| 136 | + | <dt> | |
| 137 | + | <kbd>g h</kbd> | |
| 138 | + | <kbd>g t</kbd> | |
| 139 | + | </dt> | |
| 140 | + | <dd>go home / tree</dd> | |
| 141 | + | </div> | |
| 142 | + | <div> | |
| 143 | + | <dt> | |
| 144 | + | <kbd>g c</kbd> | |
| 145 | + | <kbd>g r</kbd> | |
| 146 | + | </dt> | |
| 147 | + | <dd>go commits / refs</dd> | |
| 148 | + | </div> | |
| 149 | + | <div> | |
| 150 | + | <dt> | |
| 151 | + | <kbd>b</kbd> | |
| 152 | + | </dt> | |
| 153 | + | <dd>switch ref (branches, tags)</dd> | |
| 154 | + | </div> | |
| 155 | + | <div> | |
| 156 | + | <dt> | |
| 157 | + | <kbd>:</kbd> | |
| 158 | + | <kbd>f</kbd> | |
| 159 | + | </dt> | |
| 160 | + | <dd>go to: section, ref, file, repo</dd> | |
| 161 | + | </div> | |
| 162 | + | <div> | |
| 163 | + | <dt> | |
| 164 | + | <kbd>y</kbd> | |
| 165 | + | </dt> | |
| 166 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 167 | + | </div> | |
| 168 | + | <div> | |
| 169 | + | <dt> | |
| 170 | + | <kbd>s</kbd> | |
| 171 | + | <kbd>t</kbd> | |
| 172 | + | <kbd>c</kbd> | |
| 173 | + | <kbd>r</kbd> | |
| 174 | + | </dt> | |
| 175 | + | <dd>repo sections (when shown)</dd> | |
| 176 | + | </div> | |
| 177 | + | <div> | |
| 178 | + | <dt> | |
| 179 | + | <kbd>d</kbd> | |
| 180 | + | <kbd>u</kbd> | |
| 181 | + | <kbd>x</kbd> | |
| 182 | + | </dt> | |
| 183 | + | <dd>commit: split / unified / collapse all</dd> | |
| 184 | + | </div> | |
| 185 | + | <div> | |
| 186 | + | <dt> | |
| 187 | + | <kbd>esc</kbd> | |
| 188 | + | </dt> | |
| 189 | + | <dd>close panel</dd> | |
| 190 | + | </div> | |
| 191 | + | </dl> | |
| 192 | + | </dialog> | |
| 193 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 194 | + | <div class="panel-head"> | |
| 195 | + | <label for="refs-filter">ref></label> | |
| 196 | + | <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))"> | |
| 197 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 198 | + | </div> | |
| 199 | + | <div class="panel-cols" data-picklist> | |
| 200 | + | <section aria-label="Branches"> | |
| 201 | + | <h2>branches <span class="mute">2</span> | |
| 202 | + | </h2> | |
| 203 | + | <ul> | |
| 204 | + | <li> | |
| 205 | + | <a href="/files/tree/feature/x"> | |
| 206 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 207 | + | </li> | |
| 208 | + | <li> | |
| 209 | + | <a href="/files/tree/main" aria-current="true"> | |
| 210 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 211 | + | </li> | |
| 212 | + | </ul> | |
| 213 | + | </section> | |
| 214 | + | <section aria-label="Tags"> | |
| 215 | + | <h2>tags <span class="mute">1</span> | |
| 216 | + | </h2> | |
| 217 | + | <ul> | |
| 218 | + | <li> | |
| 219 | + | <a href="/files/tree/v1.0"> | |
| 220 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 221 | + | </li> | |
| 222 | + | </ul> | |
| 223 | + | </section> | |
| 224 | + | </div> | |
| 225 | + | </dialog> | |
| 226 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 227 | + | <div class="panel-head"> | |
| 228 | + | <label for="cmd-q" class="hi">:</label> | |
| 229 | + | <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(); | |
| 230 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 231 | + | 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(); | |
| 232 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 233 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 234 | + | 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' })) } | |
| 235 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 236 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 237 | + | </div> | |
| 238 | + | <ul id="cmd-list" class="cmd-list"> | |
| 239 | + | <li data-k="go home" data-cursor> | |
| 240 | + | <a href="/" tabindex="-1"> | |
| 241 | + | <span class="k">go</span>home</a> | |
| 242 | + | </li> | |
| 243 | + | <li data-k="section summary"> | |
| 244 | + | <a href="/files" tabindex="-1"> | |
| 245 | + | <span class="k">section</span>summary</a> | |
| 246 | + | </li> | |
| 247 | + | <li data-k="section tree"> | |
| 248 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 249 | + | <span class="k">section</span>tree</a> | |
| 250 | + | </li> | |
| 251 | + | <li data-k="section commits"> | |
| 252 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 253 | + | <span class="k">section</span>commits</a> | |
| 254 | + | </li> | |
| 255 | + | <li data-k="section refs"> | |
| 256 | + | <a href="/files/refs" tabindex="-1"> | |
| 257 | + | <span class="k">section</span>refs</a> | |
| 258 | + | </li> | |
| 259 | + | <li data-k="branch feature/x"> | |
| 260 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 261 | + | <span class="k">branch</span>feature/x</a> | |
| 262 | + | </li> | |
| 263 | + | <li data-k="branch main"> | |
| 264 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 265 | + | <span class="k">branch</span>main</a> | |
| 266 | + | </li> | |
| 267 | + | <li data-k="tag v1.0"> | |
| 268 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 269 | + | <span class="k">tag</span>v1.0</a> | |
| 270 | + | </li> | |
| 271 | + | <li data-k="file src/lib/util.go"> | |
| 272 | + | <a href="/files/blob/main/src/lib/util.go" tabindex="-1"> | |
| 273 | + | <span class="k">file</span>src/lib/util.go</a> | |
| 274 | + | </li> | |
| 275 | + | </ul> | |
| 276 | + | </dialog> | |
| 277 | + | <footer class="bar status" aria-label="Status"> | |
| 278 | + | <span class="mode">tree</span> | |
| 279 | + | <span class="st-ctx">tree · 1 entry</span> | |
| 280 | + | <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'))"> | |
| 281 | + | <span class="hi">:</span> go to…</button> | |
| 282 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 283 | + | </span> | |
| 284 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |
| 285 | + | <span class="st-src"> | |
| 286 | + | <span class="st-ver">ermite devel · </span> | |
| 287 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 288 | + | </span> | |
| 289 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 290 | + | <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/1</span> | |
| 291 | + | </footer> | |
| 292 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 293 | + | if (q('dialog[open]').count) return; | |
| 294 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 295 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 296 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 297 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 298 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 299 | + | })(1); | |
| 300 | + | k-k from:body -> ((d) => { | |
| 301 | + | if (q('dialog[open]').count) return; | |
| 302 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 303 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 304 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 305 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 306 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 307 | + | })(-1); | |
| 308 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 309 | + | 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)); | |
| 310 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 311 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 312 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 313 | + | 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()); | |
| 314 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 315 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 316 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 317 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 318 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 319 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 320 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 321 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 322 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 323 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 324 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 325 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 326 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 327 | + | k-permalink from:body -> { | |
| 328 | + | let b = document.body.dataset; | |
| 329 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 330 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 331 | + | navigator.clipboard?.writeText(location.href); | |
| 332 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 333 | + | }"> | |
| 334 | + | </div> | |
| 335 | + | </body> | |
| 336 | + | </html> | |
| @@ -0,0 +1,336 @@ | |||||
| 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>src/lib · files · 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="/files">files</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/files/tree/main">tree</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/files/tree/main">main</a> | |||
| 34 | + | </li> | |||
| 35 | + | <li> | |||
| 36 | + | <a href="/files/tree/main/src">src</a> | |||
| 37 | + | </li> | |||
| 38 | + | <li> | |||
| 39 | + | <a href="/files/tree/main/src/lib" aria-current="page">lib</a> | |||
| 40 | + | </li> | |||
| 41 | + | </ol> | |||
| 42 | + | </nav> | |||
| 43 | + | <nav class="secs" aria-label="Sections"> | |||
| 44 | + | <ul> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |||
| 50 | + | </li> | |||
| 51 | + | <li> | |||
| 52 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |||
| 53 | + | </li> | |||
| 54 | + | <li> | |||
| 55 | + | <a href="/files/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |||
| 56 | + | </li> | |||
| 57 | + | </ul> | |||
| 58 | + | </nav> | |||
| 59 | + | <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> | |||
| 60 | + | </button> | |||
| 61 | + | </header> | |||
| 62 | + | <div class="work"> | |||
| 63 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 64 | + | <div class="filter"> | |||
| 65 | + | <label for="tree-filter">filter></label> | |||
| 66 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |||
| 67 | + | </div> | |||
| 68 | + | <table class="list tree" aria-label="Entries"> | |||
| 69 | + | <thead> | |||
| 70 | + | <tr> | |||
| 71 | + | <th class="cur"> | |||
| 72 | + | </th> | |||
| 73 | + | <th class="c-kind"> | |||
| 74 | + | </th> | |||
| 75 | + | <th class="c-name">name</th> | |||
| 76 | + | <th class="c-size">size</th> | |||
| 77 | + | <th class="c-sub">last commit</th> | |||
| 78 | + | <th class="c-who">author</th> | |||
| 79 | + | <th class="c-when">date</th> | |||
| 80 | + | </tr> | |||
| 81 | + | </thead> | |||
| 82 | + | <tbody id="tree-rows" data-rows> | |||
| 83 | + | <tr data-cursor> | |||
| 84 | + | <td class="cur" aria-hidden="true"> | |||
| 85 | + | </td> | |||
| 86 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 87 | + | <td class="c-name"> | |||
| 88 | + | <a href="/files/blob/main/src/lib/util.go">util.go</a> | |||
| 89 | + | </td> | |||
| 90 | + | <td class="c-size num dim">12 B</td> | |||
| 91 | + | <td class="c-sub dim"> | |||
| 92 | + | <a href="/files/commit/SHA40">add src/lib/util.go</a> | |||
| 93 | + | </td> | |||
| 94 | + | <td class="c-who dim">T</td> | |||
| 95 | + | <td class="c-when nowrap dim"> | |||
| 96 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 97 | + | </td> | |||
| 98 | + | </tr> | |||
| 99 | + | </tbody> | |||
| 100 | + | </table> | |||
| 101 | + | </main> | |||
| 102 | + | </div> | |||
| 103 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 104 | + | <div class="panel-head"> | |||
| 105 | + | <span>keys</span> | |||
| 106 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 107 | + | </div> | |||
| 108 | + | <dl class="keys"> | |||
| 109 | + | <div> | |||
| 110 | + | <dt> | |||
| 111 | + | <kbd>j</kbd> | |||
| 112 | + | <kbd>k</kbd> | |||
| 113 | + | </dt> | |||
| 114 | + | <dd>move cursor down / up</dd> | |||
| 115 | + | </div> | |||
| 116 | + | <div> | |||
| 117 | + | <dt> | |||
| 118 | + | <kbd>enter</kbd> | |||
| 119 | + | </dt> | |||
| 120 | + | <dd>open row under cursor</dd> | |||
| 121 | + | </div> | |||
| 122 | + | <div> | |||
| 123 | + | <dt> | |||
| 124 | + | <kbd>g</kbd> | |||
| 125 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 126 | + | </dt> | |||
| 127 | + | <dd>first / last row</dd> | |||
| 128 | + | </div> | |||
| 129 | + | <div> | |||
| 130 | + | <dt> | |||
| 131 | + | <kbd>/</kbd> | |||
| 132 | + | </dt> | |||
| 133 | + | <dd>focus the filter</dd> | |||
| 134 | + | </div> | |||
| 135 | + | <div> | |||
| 136 | + | <dt> | |||
| 137 | + | <kbd>g h</kbd> | |||
| 138 | + | <kbd>g t</kbd> | |||
| 139 | + | </dt> | |||
| 140 | + | <dd>go home / tree</dd> | |||
| 141 | + | </div> | |||
| 142 | + | <div> | |||
| 143 | + | <dt> | |||
| 144 | + | <kbd>g c</kbd> | |||
| 145 | + | <kbd>g r</kbd> | |||
| 146 | + | </dt> | |||
| 147 | + | <dd>go commits / refs</dd> | |||
| 148 | + | </div> | |||
| 149 | + | <div> | |||
| 150 | + | <dt> | |||
| 151 | + | <kbd>b</kbd> | |||
| 152 | + | </dt> | |||
| 153 | + | <dd>switch ref (branches, tags)</dd> | |||
| 154 | + | </div> | |||
| 155 | + | <div> | |||
| 156 | + | <dt> | |||
| 157 | + | <kbd>:</kbd> | |||
| 158 | + | <kbd>f</kbd> | |||
| 159 | + | </dt> | |||
| 160 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 161 | + | </div> | |||
| 162 | + | <div> | |||
| 163 | + | <dt> | |||
| 164 | + | <kbd>y</kbd> | |||
| 165 | + | </dt> | |||
| 166 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 167 | + | </div> | |||
| 168 | + | <div> | |||
| 169 | + | <dt> | |||
| 170 | + | <kbd>s</kbd> | |||
| 171 | + | <kbd>t</kbd> | |||
| 172 | + | <kbd>c</kbd> | |||
| 173 | + | <kbd>r</kbd> | |||
| 174 | + | </dt> | |||
| 175 | + | <dd>repo sections (when shown)</dd> | |||
| 176 | + | </div> | |||
| 177 | + | <div> | |||
| 178 | + | <dt> | |||
| 179 | + | <kbd>d</kbd> | |||
| 180 | + | <kbd>u</kbd> | |||
| 181 | + | <kbd>x</kbd> | |||
| 182 | + | </dt> | |||
| 183 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 184 | + | </div> | |||
| 185 | + | <div> | |||
| 186 | + | <dt> | |||
| 187 | + | <kbd>esc</kbd> | |||
| 188 | + | </dt> | |||
| 189 | + | <dd>close panel</dd> | |||
| 190 | + | </div> | |||
| 191 | + | </dl> | |||
| 192 | + | </dialog> | |||
| 193 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 194 | + | <div class="panel-head"> | |||
| 195 | + | <label for="refs-filter">ref></label> | |||
| 196 | + | <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))"> | |||
| 197 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 198 | + | </div> | |||
| 199 | + | <div class="panel-cols" data-picklist> | |||
| 200 | + | <section aria-label="Branches"> | |||
| 201 | + | <h2>branches <span class="mute">2</span> | |||
| 202 | + | </h2> | |||
| 203 | + | <ul> | |||
| 204 | + | <li> | |||
| 205 | + | <a href="/files/tree/feature/x"> | |||
| 206 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |||
| 207 | + | </li> | |||
| 208 | + | <li> | |||
| 209 | + | <a href="/files/tree/main" aria-current="true"> | |||
| 210 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 211 | + | </li> | |||
| 212 | + | </ul> | |||
| 213 | + | </section> | |||
| 214 | + | <section aria-label="Tags"> | |||
| 215 | + | <h2>tags <span class="mute">1</span> | |||
| 216 | + | </h2> | |||
| 217 | + | <ul> | |||
| 218 | + | <li> | |||
| 219 | + | <a href="/files/tree/v1.0"> | |||
| 220 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |||
| 221 | + | </li> | |||
| 222 | + | </ul> | |||
| 223 | + | </section> | |||
| 224 | + | </div> | |||
| 225 | + | </dialog> | |||
| 226 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 227 | + | <div class="panel-head"> | |||
| 228 | + | <label for="cmd-q" class="hi">:</label> | |||
| 229 | + | <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(); | |||
| 230 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 231 | + | 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(); | |||
| 232 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 233 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 234 | + | 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' })) } | |||
| 235 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 236 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 237 | + | </div> | |||
| 238 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 239 | + | <li data-k="go home" data-cursor> | |||
| 240 | + | <a href="/" tabindex="-1"> | |||
| 241 | + | <span class="k">go</span>home</a> | |||
| 242 | + | </li> | |||
| 243 | + | <li data-k="section summary"> | |||
| 244 | + | <a href="/files" tabindex="-1"> | |||
| 245 | + | <span class="k">section</span>summary</a> | |||
| 246 | + | </li> | |||
| 247 | + | <li data-k="section tree"> | |||
| 248 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 249 | + | <span class="k">section</span>tree</a> | |||
| 250 | + | </li> | |||
| 251 | + | <li data-k="section commits"> | |||
| 252 | + | <a href="/files/commits/main" tabindex="-1"> | |||
| 253 | + | <span class="k">section</span>commits</a> | |||
| 254 | + | </li> | |||
| 255 | + | <li data-k="section refs"> | |||
| 256 | + | <a href="/files/refs" tabindex="-1"> | |||
| 257 | + | <span class="k">section</span>refs</a> | |||
| 258 | + | </li> | |||
| 259 | + | <li data-k="branch feature/x"> | |||
| 260 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |||
| 261 | + | <span class="k">branch</span>feature/x</a> | |||
| 262 | + | </li> | |||
| 263 | + | <li data-k="branch main"> | |||
| 264 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 265 | + | <span class="k">branch</span>main</a> | |||
| 266 | + | </li> | |||
| 267 | + | <li data-k="tag v1.0"> | |||
| 268 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |||
| 269 | + | <span class="k">tag</span>v1.0</a> | |||
| 270 | + | </li> | |||
| 271 | + | <li data-k="file src/lib/util.go"> | |||
| 272 | + | <a href="/files/blob/main/src/lib/util.go" tabindex="-1"> | |||
| 273 | + | <span class="k">file</span>src/lib/util.go</a> | |||
| 274 | + | </li> | |||
| 275 | + | </ul> | |||
| 276 | + | </dialog> | |||
| 277 | + | <footer class="bar status" aria-label="Status"> | |||
| 278 | + | <span class="mode">tree</span> | |||
| 279 | + | <span class="st-ctx">tree · 1 entry</span> | |||
| 280 | + | <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'))"> | |||
| 281 | + | <span class="hi">:</span> go to…</button> | |||
| 282 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 283 | + | </span> | |||
| 284 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |||
| 285 | + | <span class="st-src"> | |||
| 286 | + | <span class="st-ver">ermite devel · </span> | |||
| 287 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 288 | + | </span> | |||
| 289 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 290 | + | <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/1</span> | |||
| 291 | + | </footer> | |||
| 292 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 293 | + | if (q('dialog[open]').count) return; | |||
| 294 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 295 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 296 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 297 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 298 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 299 | + | })(1); | |||
| 300 | + | k-k from:body -> ((d) => { | |||
| 301 | + | if (q('dialog[open]').count) return; | |||
| 302 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 303 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 304 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 305 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 306 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 307 | + | })(-1); | |||
| 308 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 309 | + | 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)); | |||
| 310 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 311 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 312 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 313 | + | 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()); | |||
| 314 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 315 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 316 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 317 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 318 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 319 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 320 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 321 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 322 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 323 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 324 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 325 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 326 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 327 | + | k-permalink from:body -> { | |||
| 328 | + | let b = document.body.dataset; | |||
| 329 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 330 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 331 | + | navigator.clipboard?.writeText(location.href); | |||
| 332 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 333 | + | }"> | |||
| 334 | + | </div> | |||
| 335 | + | </body> | |||
| 336 | + | </html> | |||
A cmd/ermite/testdata/web/tree-root.html +609 -0
| @@ -0,0 +1,609 @@ | |||
| 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>tree · files · 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="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">tree</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/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="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/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"> | |
| 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="tree-filter">filter></label> | |
| 60 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 61 | + | </div> | |
| 62 | + | <table class="list tree" aria-label="Entries"> | |
| 63 | + | <thead> | |
| 64 | + | <tr> | |
| 65 | + | <th class="cur"> | |
| 66 | + | </th> | |
| 67 | + | <th class="c-kind"> | |
| 68 | + | </th> | |
| 69 | + | <th class="c-name">name</th> | |
| 70 | + | <th class="c-size">size</th> | |
| 71 | + | <th class="c-sub">last commit</th> | |
| 72 | + | <th class="c-who">author</th> | |
| 73 | + | <th class="c-when">date</th> | |
| 74 | + | </tr> | |
| 75 | + | </thead> | |
| 76 | + | <tbody id="tree-rows" data-rows> | |
| 77 | + | <tr data-cursor> | |
| 78 | + | <td class="cur" aria-hidden="true"> | |
| 79 | + | </td> | |
| 80 | + | <td class="c-kind" aria-hidden="true">▾</td> | |
| 81 | + | <td class="c-name"> | |
| 82 | + | <a href="/files/tree/main/docs">docs/</a> | |
| 83 | + | </td> | |
| 84 | + | <td class="c-size num dim">-</td> | |
| 85 | + | <td class="c-sub dim"> | |
| 86 | + | <a href="/files/commit/SHA40">add docs/guide.md</a> | |
| 87 | + | </td> | |
| 88 | + | <td class="c-who dim">T</td> | |
| 89 | + | <td class="c-when nowrap dim"> | |
| 90 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 91 | + | </td> | |
| 92 | + | </tr> | |
| 93 | + | <tr> | |
| 94 | + | <td class="cur" aria-hidden="true"> | |
| 95 | + | </td> | |
| 96 | + | <td class="c-kind" aria-hidden="true">▾</td> | |
| 97 | + | <td class="c-name"> | |
| 98 | + | <a href="/files/tree/main/src">src/</a> | |
| 99 | + | </td> | |
| 100 | + | <td class="c-size num dim">-</td> | |
| 101 | + | <td class="c-sub dim"> | |
| 102 | + | <a href="/files/commit/SHA40">add src/lib/util.go</a> | |
| 103 | + | </td> | |
| 104 | + | <td class="c-who dim">T</td> | |
| 105 | + | <td class="c-when nowrap dim"> | |
| 106 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 107 | + | </td> | |
| 108 | + | </tr> | |
| 109 | + | <tr> | |
| 110 | + | <td class="cur" aria-hidden="true"> | |
| 111 | + | </td> | |
| 112 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 113 | + | <td class="c-name"> | |
| 114 | + | <a href="/files/blob/main/README.md">README.md</a> | |
| 115 | + | </td> | |
| 116 | + | <td class="c-size num dim">41 B</td> | |
| 117 | + | <td class="c-sub dim"> | |
| 118 | + | <a href="/files/commit/SHA40">add README.md</a> | |
| 119 | + | </td> | |
| 120 | + | <td class="c-who dim">T</td> | |
| 121 | + | <td class="c-when nowrap dim"> | |
| 122 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 123 | + | </td> | |
| 124 | + | </tr> | |
| 125 | + | <tr> | |
| 126 | + | <td class="cur" aria-hidden="true"> | |
| 127 | + | </td> | |
| 128 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 129 | + | <td class="c-name"> | |
| 130 | + | <a href="/files/blob/main/big.txt">big.txt</a> | |
| 131 | + | </td> | |
| 132 | + | <td class="c-size num dim">1.1 MiB</td> | |
| 133 | + | <td class="c-sub dim"> | |
| 134 | + | <a href="/files/commit/SHA40">add big.txt</a> | |
| 135 | + | </td> | |
| 136 | + | <td class="c-who dim">T</td> | |
| 137 | + | <td class="c-when nowrap dim"> | |
| 138 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 139 | + | </td> | |
| 140 | + | </tr> | |
| 141 | + | <tr> | |
| 142 | + | <td class="cur" aria-hidden="true"> | |
| 143 | + | </td> | |
| 144 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 145 | + | <td class="c-name"> | |
| 146 | + | <a href="/files/blob/main/data.bin">data.bin</a> | |
| 147 | + | </td> | |
| 148 | + | <td class="c-size num dim">10 B</td> | |
| 149 | + | <td class="c-sub dim"> | |
| 150 | + | <a href="/files/commit/SHA40">add data.bin</a> | |
| 151 | + | </td> | |
| 152 | + | <td class="c-who dim">T</td> | |
| 153 | + | <td class="c-when nowrap dim"> | |
| 154 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 155 | + | </td> | |
| 156 | + | </tr> | |
| 157 | + | <tr> | |
| 158 | + | <td class="cur" aria-hidden="true"> | |
| 159 | + | </td> | |
| 160 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 161 | + | <td class="c-name"> | |
| 162 | + | <a href="/files/blob/main/empty.txt">empty.txt</a> | |
| 163 | + | </td> | |
| 164 | + | <td class="c-size num dim">0 B</td> | |
| 165 | + | <td class="c-sub dim"> | |
| 166 | + | <a href="/files/commit/SHA40">add empty.txt</a> | |
| 167 | + | </td> | |
| 168 | + | <td class="c-who dim">T</td> | |
| 169 | + | <td class="c-when nowrap dim"> | |
| 170 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 171 | + | </td> | |
| 172 | + | </tr> | |
| 173 | + | <tr> | |
| 174 | + | <td class="cur" aria-hidden="true"> | |
| 175 | + | </td> | |
| 176 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 177 | + | <td class="c-name"> | |
| 178 | + | <a href="/files/blob/main/evil.go">evil.go</a> | |
| 179 | + | </td> | |
| 180 | + | <td class="c-size num dim">87 B</td> | |
| 181 | + | <td class="c-sub dim"> | |
| 182 | + | <a href="/files/commit/SHA40">add evil.go</a> | |
| 183 | + | </td> | |
| 184 | + | <td class="c-who dim">T</td> | |
| 185 | + | <td class="c-when nowrap dim"> | |
| 186 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 187 | + | </td> | |
| 188 | + | </tr> | |
| 189 | + | <tr> | |
| 190 | + | <td class="cur" aria-hidden="true"> | |
| 191 | + | </td> | |
| 192 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 193 | + | <td class="c-name"> | |
| 194 | + | <a href="/files/blob/main/fake.jpg">fake.jpg</a> | |
| 195 | + | </td> | |
| 196 | + | <td class="c-size num dim">15 B</td> | |
| 197 | + | <td class="c-sub dim"> | |
| 198 | + | <a href="/files/commit/SHA40">add fake.jpg</a> | |
| 199 | + | </td> | |
| 200 | + | <td class="c-who dim">T</td> | |
| 201 | + | <td class="c-when nowrap dim"> | |
| 202 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 203 | + | </td> | |
| 204 | + | </tr> | |
| 205 | + | <tr> | |
| 206 | + | <td class="cur" aria-hidden="true"> | |
| 207 | + | </td> | |
| 208 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 209 | + | <td class="c-name"> | |
| 210 | + | <a href="/files/blob/main/fake.png">fake.png</a> | |
| 211 | + | </td> | |
| 212 | + | <td class="c-size num dim">39 B</td> | |
| 213 | + | <td class="c-sub dim"> | |
| 214 | + | <a href="/files/commit/SHA40">add fake.png</a> | |
| 215 | + | </td> | |
| 216 | + | <td class="c-who dim">T</td> | |
| 217 | + | <td class="c-when nowrap dim"> | |
| 218 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 219 | + | </td> | |
| 220 | + | </tr> | |
| 221 | + | <tr> | |
| 222 | + | <td class="cur" aria-hidden="true"> | |
| 223 | + | </td> | |
| 224 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 225 | + | <td class="c-name"> | |
| 226 | + | <a href="/files/blob/main/lines.txt">lines.txt</a> | |
| 227 | + | </td> | |
| 228 | + | <td class="c-size num dim">39.1 KiB</td> | |
| 229 | + | <td class="c-sub dim"> | |
| 230 | + | <a href="/files/commit/SHA40">add lines.txt</a> | |
| 231 | + | </td> | |
| 232 | + | <td class="c-who dim">T</td> | |
| 233 | + | <td class="c-when nowrap dim"> | |
| 234 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 235 | + | </td> | |
| 236 | + | </tr> | |
| 237 | + | <tr> | |
| 238 | + | <td class="cur" aria-hidden="true"> | |
| 239 | + | </td> | |
| 240 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 241 | + | <td class="c-name"> | |
| 242 | + | <a href="/files/blob/main/logo.svg">logo.svg</a> | |
| 243 | + | </td> | |
| 244 | + | <td class="c-size num dim">89 B</td> | |
| 245 | + | <td class="c-sub dim"> | |
| 246 | + | <a href="/files/commit/SHA40">add logo.svg</a> | |
| 247 | + | </td> | |
| 248 | + | <td class="c-who dim">T</td> | |
| 249 | + | <td class="c-when nowrap dim"> | |
| 250 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 251 | + | </td> | |
| 252 | + | </tr> | |
| 253 | + | <tr> | |
| 254 | + | <td class="cur" aria-hidden="true"> | |
| 255 | + | </td> | |
| 256 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 257 | + | <td class="c-name"> | |
| 258 | + | <a href="/files/blob/main/main.go">main.go</a> | |
| 259 | + | </td> | |
| 260 | + | <td class="c-size num dim">87 B</td> | |
| 261 | + | <td class="c-sub dim"> | |
| 262 | + | <a href="/files/commit/SHA40">add main.go</a> | |
| 263 | + | </td> | |
| 264 | + | <td class="c-who dim">T</td> | |
| 265 | + | <td class="c-when nowrap dim"> | |
| 266 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 267 | + | </td> | |
| 268 | + | </tr> | |
| 269 | + | <tr> | |
| 270 | + | <td class="cur" aria-hidden="true"> | |
| 271 | + | </td> | |
| 272 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 273 | + | <td class="c-name"> | |
| 274 | + | <a href="/files/blob/main/notes.txt">notes.txt</a> | |
| 275 | + | </td> | |
| 276 | + | <td class="c-size num dim">24 B</td> | |
| 277 | + | <td class="c-sub dim"> | |
| 278 | + | <a href="/files/commit/SHA40">add notes.txt</a> | |
| 279 | + | </td> | |
| 280 | + | <td class="c-who dim">T</td> | |
| 281 | + | <td class="c-when nowrap dim"> | |
| 282 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 283 | + | </td> | |
| 284 | + | </tr> | |
| 285 | + | <tr> | |
| 286 | + | <td class="cur" aria-hidden="true"> | |
| 287 | + | </td> | |
| 288 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 289 | + | <td class="c-name"> | |
| 290 | + | <a href="/files/blob/main/page.html">page.html</a> | |
| 291 | + | </td> | |
| 292 | + | <td class="c-size num dim">26 B</td> | |
| 293 | + | <td class="c-sub dim"> | |
| 294 | + | <a href="/files/commit/SHA40">add page.html</a> | |
| 295 | + | </td> | |
| 296 | + | <td class="c-who dim">T</td> | |
| 297 | + | <td class="c-when nowrap dim"> | |
| 298 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 299 | + | </td> | |
| 300 | + | </tr> | |
| 301 | + | <tr> | |
| 302 | + | <td class="cur" aria-hidden="true"> | |
| 303 | + | </td> | |
| 304 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 305 | + | <td class="c-name"> | |
| 306 | + | <a href="/files/blob/main/pic.png">pic.png</a> | |
| 307 | + | </td> | |
| 308 | + | <td class="c-size num dim">88 B</td> | |
| 309 | + | <td class="c-sub dim"> | |
| 310 | + | <a href="/files/commit/SHA40">add pic.png</a> | |
| 311 | + | </td> | |
| 312 | + | <td class="c-who dim">T</td> | |
| 313 | + | <td class="c-when nowrap dim"> | |
| 314 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 315 | + | </td> | |
| 316 | + | </tr> | |
| 317 | + | </tbody> | |
| 318 | + | </table> | |
| 319 | + | <h2 class="rule-h"> | |
| 320 | + | <span>README.md</span> | |
| 321 | + | </h2> | |
| 322 | + | <article class="prose" aria-label="README.md"> | |
| 323 | + | <h1 id="md-files">Files</h1> | |
| 324 | + | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | |
| 325 | + | </article> | |
| 326 | + | </main> | |
| 327 | + | </div> | |
| 328 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 329 | + | <div class="panel-head"> | |
| 330 | + | <span>keys</span> | |
| 331 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 332 | + | </div> | |
| 333 | + | <dl class="keys"> | |
| 334 | + | <div> | |
| 335 | + | <dt> | |
| 336 | + | <kbd>j</kbd> | |
| 337 | + | <kbd>k</kbd> | |
| 338 | + | </dt> | |
| 339 | + | <dd>move cursor down / up</dd> | |
| 340 | + | </div> | |
| 341 | + | <div> | |
| 342 | + | <dt> | |
| 343 | + | <kbd>enter</kbd> | |
| 344 | + | </dt> | |
| 345 | + | <dd>open row under cursor</dd> | |
| 346 | + | </div> | |
| 347 | + | <div> | |
| 348 | + | <dt> | |
| 349 | + | <kbd>g</kbd> | |
| 350 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 351 | + | </dt> | |
| 352 | + | <dd>first / last row</dd> | |
| 353 | + | </div> | |
| 354 | + | <div> | |
| 355 | + | <dt> | |
| 356 | + | <kbd>/</kbd> | |
| 357 | + | </dt> | |
| 358 | + | <dd>focus the filter</dd> | |
| 359 | + | </div> | |
| 360 | + | <div> | |
| 361 | + | <dt> | |
| 362 | + | <kbd>g h</kbd> | |
| 363 | + | <kbd>g t</kbd> | |
| 364 | + | </dt> | |
| 365 | + | <dd>go home / tree</dd> | |
| 366 | + | </div> | |
| 367 | + | <div> | |
| 368 | + | <dt> | |
| 369 | + | <kbd>g c</kbd> | |
| 370 | + | <kbd>g r</kbd> | |
| 371 | + | </dt> | |
| 372 | + | <dd>go commits / refs</dd> | |
| 373 | + | </div> | |
| 374 | + | <div> | |
| 375 | + | <dt> | |
| 376 | + | <kbd>b</kbd> | |
| 377 | + | </dt> | |
| 378 | + | <dd>switch ref (branches, tags)</dd> | |
| 379 | + | </div> | |
| 380 | + | <div> | |
| 381 | + | <dt> | |
| 382 | + | <kbd>:</kbd> | |
| 383 | + | <kbd>f</kbd> | |
| 384 | + | </dt> | |
| 385 | + | <dd>go to: section, ref, file, repo</dd> | |
| 386 | + | </div> | |
| 387 | + | <div> | |
| 388 | + | <dt> | |
| 389 | + | <kbd>y</kbd> | |
| 390 | + | </dt> | |
| 391 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 392 | + | </div> | |
| 393 | + | <div> | |
| 394 | + | <dt> | |
| 395 | + | <kbd>s</kbd> | |
| 396 | + | <kbd>t</kbd> | |
| 397 | + | <kbd>c</kbd> | |
| 398 | + | <kbd>r</kbd> | |
| 399 | + | </dt> | |
| 400 | + | <dd>repo sections (when shown)</dd> | |
| 401 | + | </div> | |
| 402 | + | <div> | |
| 403 | + | <dt> | |
| 404 | + | <kbd>d</kbd> | |
| 405 | + | <kbd>u</kbd> | |
| 406 | + | <kbd>x</kbd> | |
| 407 | + | </dt> | |
| 408 | + | <dd>commit: split / unified / collapse all</dd> | |
| 409 | + | </div> | |
| 410 | + | <div> | |
| 411 | + | <dt> | |
| 412 | + | <kbd>esc</kbd> | |
| 413 | + | </dt> | |
| 414 | + | <dd>close panel</dd> | |
| 415 | + | </div> | |
| 416 | + | </dl> | |
| 417 | + | </dialog> | |
| 418 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 419 | + | <div class="panel-head"> | |
| 420 | + | <label for="refs-filter">ref></label> | |
| 421 | + | <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))"> | |
| 422 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 423 | + | </div> | |
| 424 | + | <div class="panel-cols" data-picklist> | |
| 425 | + | <section aria-label="Branches"> | |
| 426 | + | <h2>branches <span class="mute">2</span> | |
| 427 | + | </h2> | |
| 428 | + | <ul> | |
| 429 | + | <li> | |
| 430 | + | <a href="/files/tree/feature/x"> | |
| 431 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 432 | + | </li> | |
| 433 | + | <li> | |
| 434 | + | <a href="/files/tree/main" aria-current="true"> | |
| 435 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 436 | + | </li> | |
| 437 | + | </ul> | |
| 438 | + | </section> | |
| 439 | + | <section aria-label="Tags"> | |
| 440 | + | <h2>tags <span class="mute">1</span> | |
| 441 | + | </h2> | |
| 442 | + | <ul> | |
| 443 | + | <li> | |
| 444 | + | <a href="/files/tree/v1.0"> | |
| 445 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 446 | + | </li> | |
| 447 | + | </ul> | |
| 448 | + | </section> | |
| 449 | + | </div> | |
| 450 | + | </dialog> | |
| 451 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 452 | + | <div class="panel-head"> | |
| 453 | + | <label for="cmd-q" class="hi">:</label> | |
| 454 | + | <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(); | |
| 455 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 456 | + | 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(); | |
| 457 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 458 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 459 | + | 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' })) } | |
| 460 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 461 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 462 | + | </div> | |
| 463 | + | <ul id="cmd-list" class="cmd-list"> | |
| 464 | + | <li data-k="go home" data-cursor> | |
| 465 | + | <a href="/" tabindex="-1"> | |
| 466 | + | <span class="k">go</span>home</a> | |
| 467 | + | </li> | |
| 468 | + | <li data-k="section summary"> | |
| 469 | + | <a href="/files" tabindex="-1"> | |
| 470 | + | <span class="k">section</span>summary</a> | |
| 471 | + | </li> | |
| 472 | + | <li data-k="section tree"> | |
| 473 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 474 | + | <span class="k">section</span>tree</a> | |
| 475 | + | </li> | |
| 476 | + | <li data-k="section commits"> | |
| 477 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 478 | + | <span class="k">section</span>commits</a> | |
| 479 | + | </li> | |
| 480 | + | <li data-k="section refs"> | |
| 481 | + | <a href="/files/refs" tabindex="-1"> | |
| 482 | + | <span class="k">section</span>refs</a> | |
| 483 | + | </li> | |
| 484 | + | <li data-k="branch feature/x"> | |
| 485 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 486 | + | <span class="k">branch</span>feature/x</a> | |
| 487 | + | </li> | |
| 488 | + | <li data-k="branch main"> | |
| 489 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 490 | + | <span class="k">branch</span>main</a> | |
| 491 | + | </li> | |
| 492 | + | <li data-k="tag v1.0"> | |
| 493 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 494 | + | <span class="k">tag</span>v1.0</a> | |
| 495 | + | </li> | |
| 496 | + | <li data-k="file readme.md"> | |
| 497 | + | <a href="/files/blob/main/README.md" tabindex="-1"> | |
| 498 | + | <span class="k">file</span>README.md</a> | |
| 499 | + | </li> | |
| 500 | + | <li data-k="file big.txt"> | |
| 501 | + | <a href="/files/blob/main/big.txt" tabindex="-1"> | |
| 502 | + | <span class="k">file</span>big.txt</a> | |
| 503 | + | </li> | |
| 504 | + | <li data-k="file data.bin"> | |
| 505 | + | <a href="/files/blob/main/data.bin" tabindex="-1"> | |
| 506 | + | <span class="k">file</span>data.bin</a> | |
| 507 | + | </li> | |
| 508 | + | <li data-k="file empty.txt"> | |
| 509 | + | <a href="/files/blob/main/empty.txt" tabindex="-1"> | |
| 510 | + | <span class="k">file</span>empty.txt</a> | |
| 511 | + | </li> | |
| 512 | + | <li data-k="file evil.go"> | |
| 513 | + | <a href="/files/blob/main/evil.go" tabindex="-1"> | |
| 514 | + | <span class="k">file</span>evil.go</a> | |
| 515 | + | </li> | |
| 516 | + | <li data-k="file fake.jpg"> | |
| 517 | + | <a href="/files/blob/main/fake.jpg" tabindex="-1"> | |
| 518 | + | <span class="k">file</span>fake.jpg</a> | |
| 519 | + | </li> | |
| 520 | + | <li data-k="file fake.png"> | |
| 521 | + | <a href="/files/blob/main/fake.png" tabindex="-1"> | |
| 522 | + | <span class="k">file</span>fake.png</a> | |
| 523 | + | </li> | |
| 524 | + | <li data-k="file lines.txt"> | |
| 525 | + | <a href="/files/blob/main/lines.txt" tabindex="-1"> | |
| 526 | + | <span class="k">file</span>lines.txt</a> | |
| 527 | + | </li> | |
| 528 | + | <li data-k="file logo.svg"> | |
| 529 | + | <a href="/files/blob/main/logo.svg" tabindex="-1"> | |
| 530 | + | <span class="k">file</span>logo.svg</a> | |
| 531 | + | </li> | |
| 532 | + | <li data-k="file main.go"> | |
| 533 | + | <a href="/files/blob/main/main.go" tabindex="-1"> | |
| 534 | + | <span class="k">file</span>main.go</a> | |
| 535 | + | </li> | |
| 536 | + | <li data-k="file notes.txt"> | |
| 537 | + | <a href="/files/blob/main/notes.txt" tabindex="-1"> | |
| 538 | + | <span class="k">file</span>notes.txt</a> | |
| 539 | + | </li> | |
| 540 | + | <li data-k="file page.html"> | |
| 541 | + | <a href="/files/blob/main/page.html" tabindex="-1"> | |
| 542 | + | <span class="k">file</span>page.html</a> | |
| 543 | + | </li> | |
| 544 | + | <li data-k="file pic.png"> | |
| 545 | + | <a href="/files/blob/main/pic.png" tabindex="-1"> | |
| 546 | + | <span class="k">file</span>pic.png</a> | |
| 547 | + | </li> | |
| 548 | + | </ul> | |
| 549 | + | </dialog> | |
| 550 | + | <footer class="bar status" aria-label="Status"> | |
| 551 | + | <span class="mode">tree</span> | |
| 552 | + | <span class="st-ctx">tree · 15 entries</span> | |
| 553 | + | <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'))"> | |
| 554 | + | <span class="hi">:</span> go to…</button> | |
| 555 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 556 | + | </span> | |
| 557 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |
| 558 | + | <span class="st-src"> | |
| 559 | + | <span class="st-ver">ermite devel · </span> | |
| 560 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 561 | + | </span> | |
| 562 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 563 | + | <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/15</span> | |
| 564 | + | </footer> | |
| 565 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 566 | + | if (q('dialog[open]').count) return; | |
| 567 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 568 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 569 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 570 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 571 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 572 | + | })(1); | |
| 573 | + | k-k from:body -> ((d) => { | |
| 574 | + | if (q('dialog[open]').count) return; | |
| 575 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 576 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 577 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 578 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 579 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 580 | + | })(-1); | |
| 581 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 582 | + | 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)); | |
| 583 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 584 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 585 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 586 | + | 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()); | |
| 587 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 588 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 589 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 590 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 591 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 592 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 593 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 594 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 595 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 596 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 597 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 598 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 599 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 600 | + | k-permalink from:body -> { | |
| 601 | + | let b = document.body.dataset; | |
| 602 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 603 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 604 | + | navigator.clipboard?.writeText(location.href); | |
| 605 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 606 | + | }"> | |
| 607 | + | </div> | |
| 608 | + | </body> | |
| 609 | + | </html> | |
| @@ -0,0 +1,609 @@ | |||||
| 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>tree · files · 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="/files">files</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/files/tree/main">tree</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/files/tree/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="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 41 | + | </li> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/files/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"> | |||
| 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="tree-filter">filter></label> | |||
| 60 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |||
| 61 | + | </div> | |||
| 62 | + | <table class="list tree" aria-label="Entries"> | |||
| 63 | + | <thead> | |||
| 64 | + | <tr> | |||
| 65 | + | <th class="cur"> | |||
| 66 | + | </th> | |||
| 67 | + | <th class="c-kind"> | |||
| 68 | + | </th> | |||
| 69 | + | <th class="c-name">name</th> | |||
| 70 | + | <th class="c-size">size</th> | |||
| 71 | + | <th class="c-sub">last commit</th> | |||
| 72 | + | <th class="c-who">author</th> | |||
| 73 | + | <th class="c-when">date</th> | |||
| 74 | + | </tr> | |||
| 75 | + | </thead> | |||
| 76 | + | <tbody id="tree-rows" data-rows> | |||
| 77 | + | <tr data-cursor> | |||
| 78 | + | <td class="cur" aria-hidden="true"> | |||
| 79 | + | </td> | |||
| 80 | + | <td class="c-kind" aria-hidden="true">▾</td> | |||
| 81 | + | <td class="c-name"> | |||
| 82 | + | <a href="/files/tree/main/docs">docs/</a> | |||
| 83 | + | </td> | |||
| 84 | + | <td class="c-size num dim">-</td> | |||
| 85 | + | <td class="c-sub dim"> | |||
| 86 | + | <a href="/files/commit/SHA40">add docs/guide.md</a> | |||
| 87 | + | </td> | |||
| 88 | + | <td class="c-who dim">T</td> | |||
| 89 | + | <td class="c-when nowrap dim"> | |||
| 90 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 91 | + | </td> | |||
| 92 | + | </tr> | |||
| 93 | + | <tr> | |||
| 94 | + | <td class="cur" aria-hidden="true"> | |||
| 95 | + | </td> | |||
| 96 | + | <td class="c-kind" aria-hidden="true">▾</td> | |||
| 97 | + | <td class="c-name"> | |||
| 98 | + | <a href="/files/tree/main/src">src/</a> | |||
| 99 | + | </td> | |||
| 100 | + | <td class="c-size num dim">-</td> | |||
| 101 | + | <td class="c-sub dim"> | |||
| 102 | + | <a href="/files/commit/SHA40">add src/lib/util.go</a> | |||
| 103 | + | </td> | |||
| 104 | + | <td class="c-who dim">T</td> | |||
| 105 | + | <td class="c-when nowrap dim"> | |||
| 106 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 107 | + | </td> | |||
| 108 | + | </tr> | |||
| 109 | + | <tr> | |||
| 110 | + | <td class="cur" aria-hidden="true"> | |||
| 111 | + | </td> | |||
| 112 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 113 | + | <td class="c-name"> | |||
| 114 | + | <a href="/files/blob/main/README.md">README.md</a> | |||
| 115 | + | </td> | |||
| 116 | + | <td class="c-size num dim">41 B</td> | |||
| 117 | + | <td class="c-sub dim"> | |||
| 118 | + | <a href="/files/commit/SHA40">add README.md</a> | |||
| 119 | + | </td> | |||
| 120 | + | <td class="c-who dim">T</td> | |||
| 121 | + | <td class="c-when nowrap dim"> | |||
| 122 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 123 | + | </td> | |||
| 124 | + | </tr> | |||
| 125 | + | <tr> | |||
| 126 | + | <td class="cur" aria-hidden="true"> | |||
| 127 | + | </td> | |||
| 128 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 129 | + | <td class="c-name"> | |||
| 130 | + | <a href="/files/blob/main/big.txt">big.txt</a> | |||
| 131 | + | </td> | |||
| 132 | + | <td class="c-size num dim">1.1 MiB</td> | |||
| 133 | + | <td class="c-sub dim"> | |||
| 134 | + | <a href="/files/commit/SHA40">add big.txt</a> | |||
| 135 | + | </td> | |||
| 136 | + | <td class="c-who dim">T</td> | |||
| 137 | + | <td class="c-when nowrap dim"> | |||
| 138 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 139 | + | </td> | |||
| 140 | + | </tr> | |||
| 141 | + | <tr> | |||
| 142 | + | <td class="cur" aria-hidden="true"> | |||
| 143 | + | </td> | |||
| 144 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 145 | + | <td class="c-name"> | |||
| 146 | + | <a href="/files/blob/main/data.bin">data.bin</a> | |||
| 147 | + | </td> | |||
| 148 | + | <td class="c-size num dim">10 B</td> | |||
| 149 | + | <td class="c-sub dim"> | |||
| 150 | + | <a href="/files/commit/SHA40">add data.bin</a> | |||
| 151 | + | </td> | |||
| 152 | + | <td class="c-who dim">T</td> | |||
| 153 | + | <td class="c-when nowrap dim"> | |||
| 154 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 155 | + | </td> | |||
| 156 | + | </tr> | |||
| 157 | + | <tr> | |||
| 158 | + | <td class="cur" aria-hidden="true"> | |||
| 159 | + | </td> | |||
| 160 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 161 | + | <td class="c-name"> | |||
| 162 | + | <a href="/files/blob/main/empty.txt">empty.txt</a> | |||
| 163 | + | </td> | |||
| 164 | + | <td class="c-size num dim">0 B</td> | |||
| 165 | + | <td class="c-sub dim"> | |||
| 166 | + | <a href="/files/commit/SHA40">add empty.txt</a> | |||
| 167 | + | </td> | |||
| 168 | + | <td class="c-who dim">T</td> | |||
| 169 | + | <td class="c-when nowrap dim"> | |||
| 170 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 171 | + | </td> | |||
| 172 | + | </tr> | |||
| 173 | + | <tr> | |||
| 174 | + | <td class="cur" aria-hidden="true"> | |||
| 175 | + | </td> | |||
| 176 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 177 | + | <td class="c-name"> | |||
| 178 | + | <a href="/files/blob/main/evil.go">evil.go</a> | |||
| 179 | + | </td> | |||
| 180 | + | <td class="c-size num dim">87 B</td> | |||
| 181 | + | <td class="c-sub dim"> | |||
| 182 | + | <a href="/files/commit/SHA40">add evil.go</a> | |||
| 183 | + | </td> | |||
| 184 | + | <td class="c-who dim">T</td> | |||
| 185 | + | <td class="c-when nowrap dim"> | |||
| 186 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 187 | + | </td> | |||
| 188 | + | </tr> | |||
| 189 | + | <tr> | |||
| 190 | + | <td class="cur" aria-hidden="true"> | |||
| 191 | + | </td> | |||
| 192 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 193 | + | <td class="c-name"> | |||
| 194 | + | <a href="/files/blob/main/fake.jpg">fake.jpg</a> | |||
| 195 | + | </td> | |||
| 196 | + | <td class="c-size num dim">15 B</td> | |||
| 197 | + | <td class="c-sub dim"> | |||
| 198 | + | <a href="/files/commit/SHA40">add fake.jpg</a> | |||
| 199 | + | </td> | |||
| 200 | + | <td class="c-who dim">T</td> | |||
| 201 | + | <td class="c-when nowrap dim"> | |||
| 202 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 203 | + | </td> | |||
| 204 | + | </tr> | |||
| 205 | + | <tr> | |||
| 206 | + | <td class="cur" aria-hidden="true"> | |||
| 207 | + | </td> | |||
| 208 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 209 | + | <td class="c-name"> | |||
| 210 | + | <a href="/files/blob/main/fake.png">fake.png</a> | |||
| 211 | + | </td> | |||
| 212 | + | <td class="c-size num dim">39 B</td> | |||
| 213 | + | <td class="c-sub dim"> | |||
| 214 | + | <a href="/files/commit/SHA40">add fake.png</a> | |||
| 215 | + | </td> | |||
| 216 | + | <td class="c-who dim">T</td> | |||
| 217 | + | <td class="c-when nowrap dim"> | |||
| 218 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 219 | + | </td> | |||
| 220 | + | </tr> | |||
| 221 | + | <tr> | |||
| 222 | + | <td class="cur" aria-hidden="true"> | |||
| 223 | + | </td> | |||
| 224 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 225 | + | <td class="c-name"> | |||
| 226 | + | <a href="/files/blob/main/lines.txt">lines.txt</a> | |||
| 227 | + | </td> | |||
| 228 | + | <td class="c-size num dim">39.1 KiB</td> | |||
| 229 | + | <td class="c-sub dim"> | |||
| 230 | + | <a href="/files/commit/SHA40">add lines.txt</a> | |||
| 231 | + | </td> | |||
| 232 | + | <td class="c-who dim">T</td> | |||
| 233 | + | <td class="c-when nowrap dim"> | |||
| 234 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 235 | + | </td> | |||
| 236 | + | </tr> | |||
| 237 | + | <tr> | |||
| 238 | + | <td class="cur" aria-hidden="true"> | |||
| 239 | + | </td> | |||
| 240 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 241 | + | <td class="c-name"> | |||
| 242 | + | <a href="/files/blob/main/logo.svg">logo.svg</a> | |||
| 243 | + | </td> | |||
| 244 | + | <td class="c-size num dim">89 B</td> | |||
| 245 | + | <td class="c-sub dim"> | |||
| 246 | + | <a href="/files/commit/SHA40">add logo.svg</a> | |||
| 247 | + | </td> | |||
| 248 | + | <td class="c-who dim">T</td> | |||
| 249 | + | <td class="c-when nowrap dim"> | |||
| 250 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 251 | + | </td> | |||
| 252 | + | </tr> | |||
| 253 | + | <tr> | |||
| 254 | + | <td class="cur" aria-hidden="true"> | |||
| 255 | + | </td> | |||
| 256 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 257 | + | <td class="c-name"> | |||
| 258 | + | <a href="/files/blob/main/main.go">main.go</a> | |||
| 259 | + | </td> | |||
| 260 | + | <td class="c-size num dim">87 B</td> | |||
| 261 | + | <td class="c-sub dim"> | |||
| 262 | + | <a href="/files/commit/SHA40">add main.go</a> | |||
| 263 | + | </td> | |||
| 264 | + | <td class="c-who dim">T</td> | |||
| 265 | + | <td class="c-when nowrap dim"> | |||
| 266 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 267 | + | </td> | |||
| 268 | + | </tr> | |||
| 269 | + | <tr> | |||
| 270 | + | <td class="cur" aria-hidden="true"> | |||
| 271 | + | </td> | |||
| 272 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 273 | + | <td class="c-name"> | |||
| 274 | + | <a href="/files/blob/main/notes.txt">notes.txt</a> | |||
| 275 | + | </td> | |||
| 276 | + | <td class="c-size num dim">24 B</td> | |||
| 277 | + | <td class="c-sub dim"> | |||
| 278 | + | <a href="/files/commit/SHA40">add notes.txt</a> | |||
| 279 | + | </td> | |||
| 280 | + | <td class="c-who dim">T</td> | |||
| 281 | + | <td class="c-when nowrap dim"> | |||
| 282 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 283 | + | </td> | |||
| 284 | + | </tr> | |||
| 285 | + | <tr> | |||
| 286 | + | <td class="cur" aria-hidden="true"> | |||
| 287 | + | </td> | |||
| 288 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 289 | + | <td class="c-name"> | |||
| 290 | + | <a href="/files/blob/main/page.html">page.html</a> | |||
| 291 | + | </td> | |||
| 292 | + | <td class="c-size num dim">26 B</td> | |||
| 293 | + | <td class="c-sub dim"> | |||
| 294 | + | <a href="/files/commit/SHA40">add page.html</a> | |||
| 295 | + | </td> | |||
| 296 | + | <td class="c-who dim">T</td> | |||
| 297 | + | <td class="c-when nowrap dim"> | |||
| 298 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 299 | + | </td> | |||
| 300 | + | </tr> | |||
| 301 | + | <tr> | |||
| 302 | + | <td class="cur" aria-hidden="true"> | |||
| 303 | + | </td> | |||
| 304 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 305 | + | <td class="c-name"> | |||
| 306 | + | <a href="/files/blob/main/pic.png">pic.png</a> | |||
| 307 | + | </td> | |||
| 308 | + | <td class="c-size num dim">88 B</td> | |||
| 309 | + | <td class="c-sub dim"> | |||
| 310 | + | <a href="/files/commit/SHA40">add pic.png</a> | |||
| 311 | + | </td> | |||
| 312 | + | <td class="c-who dim">T</td> | |||
| 313 | + | <td class="c-when nowrap dim"> | |||
| 314 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 315 | + | </td> | |||
| 316 | + | </tr> | |||
| 317 | + | </tbody> | |||
| 318 | + | </table> | |||
| 319 | + | <h2 class="rule-h"> | |||
| 320 | + | <span>README.md</span> | |||
| 321 | + | </h2> | |||
| 322 | + | <article class="prose" aria-label="README.md"> | |||
| 323 | + | <h1 id="md-files">Files</h1> | |||
| 324 | + | <p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p> | |||
| 325 | + | </article> | |||
| 326 | + | </main> | |||
| 327 | + | </div> | |||
| 328 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 329 | + | <div class="panel-head"> | |||
| 330 | + | <span>keys</span> | |||
| 331 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 332 | + | </div> | |||
| 333 | + | <dl class="keys"> | |||
| 334 | + | <div> | |||
| 335 | + | <dt> | |||
| 336 | + | <kbd>j</kbd> | |||
| 337 | + | <kbd>k</kbd> | |||
| 338 | + | </dt> | |||
| 339 | + | <dd>move cursor down / up</dd> | |||
| 340 | + | </div> | |||
| 341 | + | <div> | |||
| 342 | + | <dt> | |||
| 343 | + | <kbd>enter</kbd> | |||
| 344 | + | </dt> | |||
| 345 | + | <dd>open row under cursor</dd> | |||
| 346 | + | </div> | |||
| 347 | + | <div> | |||
| 348 | + | <dt> | |||
| 349 | + | <kbd>g</kbd> | |||
| 350 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 351 | + | </dt> | |||
| 352 | + | <dd>first / last row</dd> | |||
| 353 | + | </div> | |||
| 354 | + | <div> | |||
| 355 | + | <dt> | |||
| 356 | + | <kbd>/</kbd> | |||
| 357 | + | </dt> | |||
| 358 | + | <dd>focus the filter</dd> | |||
| 359 | + | </div> | |||
| 360 | + | <div> | |||
| 361 | + | <dt> | |||
| 362 | + | <kbd>g h</kbd> | |||
| 363 | + | <kbd>g t</kbd> | |||
| 364 | + | </dt> | |||
| 365 | + | <dd>go home / tree</dd> | |||
| 366 | + | </div> | |||
| 367 | + | <div> | |||
| 368 | + | <dt> | |||
| 369 | + | <kbd>g c</kbd> | |||
| 370 | + | <kbd>g r</kbd> | |||
| 371 | + | </dt> | |||
| 372 | + | <dd>go commits / refs</dd> | |||
| 373 | + | </div> | |||
| 374 | + | <div> | |||
| 375 | + | <dt> | |||
| 376 | + | <kbd>b</kbd> | |||
| 377 | + | </dt> | |||
| 378 | + | <dd>switch ref (branches, tags)</dd> | |||
| 379 | + | </div> | |||
| 380 | + | <div> | |||
| 381 | + | <dt> | |||
| 382 | + | <kbd>:</kbd> | |||
| 383 | + | <kbd>f</kbd> | |||
| 384 | + | </dt> | |||
| 385 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 386 | + | </div> | |||
| 387 | + | <div> | |||
| 388 | + | <dt> | |||
| 389 | + | <kbd>y</kbd> | |||
| 390 | + | </dt> | |||
| 391 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 392 | + | </div> | |||
| 393 | + | <div> | |||
| 394 | + | <dt> | |||
| 395 | + | <kbd>s</kbd> | |||
| 396 | + | <kbd>t</kbd> | |||
| 397 | + | <kbd>c</kbd> | |||
| 398 | + | <kbd>r</kbd> | |||
| 399 | + | </dt> | |||
| 400 | + | <dd>repo sections (when shown)</dd> | |||
| 401 | + | </div> | |||
| 402 | + | <div> | |||
| 403 | + | <dt> | |||
| 404 | + | <kbd>d</kbd> | |||
| 405 | + | <kbd>u</kbd> | |||
| 406 | + | <kbd>x</kbd> | |||
| 407 | + | </dt> | |||
| 408 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 409 | + | </div> | |||
| 410 | + | <div> | |||
| 411 | + | <dt> | |||
| 412 | + | <kbd>esc</kbd> | |||
| 413 | + | </dt> | |||
| 414 | + | <dd>close panel</dd> | |||
| 415 | + | </div> | |||
| 416 | + | </dl> | |||
| 417 | + | </dialog> | |||
| 418 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 419 | + | <div class="panel-head"> | |||
| 420 | + | <label for="refs-filter">ref></label> | |||
| 421 | + | <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))"> | |||
| 422 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 423 | + | </div> | |||
| 424 | + | <div class="panel-cols" data-picklist> | |||
| 425 | + | <section aria-label="Branches"> | |||
| 426 | + | <h2>branches <span class="mute">2</span> | |||
| 427 | + | </h2> | |||
| 428 | + | <ul> | |||
| 429 | + | <li> | |||
| 430 | + | <a href="/files/tree/feature/x"> | |||
| 431 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |||
| 432 | + | </li> | |||
| 433 | + | <li> | |||
| 434 | + | <a href="/files/tree/main" aria-current="true"> | |||
| 435 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 436 | + | </li> | |||
| 437 | + | </ul> | |||
| 438 | + | </section> | |||
| 439 | + | <section aria-label="Tags"> | |||
| 440 | + | <h2>tags <span class="mute">1</span> | |||
| 441 | + | </h2> | |||
| 442 | + | <ul> | |||
| 443 | + | <li> | |||
| 444 | + | <a href="/files/tree/v1.0"> | |||
| 445 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |||
| 446 | + | </li> | |||
| 447 | + | </ul> | |||
| 448 | + | </section> | |||
| 449 | + | </div> | |||
| 450 | + | </dialog> | |||
| 451 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 452 | + | <div class="panel-head"> | |||
| 453 | + | <label for="cmd-q" class="hi">:</label> | |||
| 454 | + | <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(); | |||
| 455 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 456 | + | 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(); | |||
| 457 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 458 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 459 | + | 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' })) } | |||
| 460 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 461 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 462 | + | </div> | |||
| 463 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 464 | + | <li data-k="go home" data-cursor> | |||
| 465 | + | <a href="/" tabindex="-1"> | |||
| 466 | + | <span class="k">go</span>home</a> | |||
| 467 | + | </li> | |||
| 468 | + | <li data-k="section summary"> | |||
| 469 | + | <a href="/files" tabindex="-1"> | |||
| 470 | + | <span class="k">section</span>summary</a> | |||
| 471 | + | </li> | |||
| 472 | + | <li data-k="section tree"> | |||
| 473 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 474 | + | <span class="k">section</span>tree</a> | |||
| 475 | + | </li> | |||
| 476 | + | <li data-k="section commits"> | |||
| 477 | + | <a href="/files/commits/main" tabindex="-1"> | |||
| 478 | + | <span class="k">section</span>commits</a> | |||
| 479 | + | </li> | |||
| 480 | + | <li data-k="section refs"> | |||
| 481 | + | <a href="/files/refs" tabindex="-1"> | |||
| 482 | + | <span class="k">section</span>refs</a> | |||
| 483 | + | </li> | |||
| 484 | + | <li data-k="branch feature/x"> | |||
| 485 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |||
| 486 | + | <span class="k">branch</span>feature/x</a> | |||
| 487 | + | </li> | |||
| 488 | + | <li data-k="branch main"> | |||
| 489 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 490 | + | <span class="k">branch</span>main</a> | |||
| 491 | + | </li> | |||
| 492 | + | <li data-k="tag v1.0"> | |||
| 493 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |||
| 494 | + | <span class="k">tag</span>v1.0</a> | |||
| 495 | + | </li> | |||
| 496 | + | <li data-k="file readme.md"> | |||
| 497 | + | <a href="/files/blob/main/README.md" tabindex="-1"> | |||
| 498 | + | <span class="k">file</span>README.md</a> | |||
| 499 | + | </li> | |||
| 500 | + | <li data-k="file big.txt"> | |||
| 501 | + | <a href="/files/blob/main/big.txt" tabindex="-1"> | |||
| 502 | + | <span class="k">file</span>big.txt</a> | |||
| 503 | + | </li> | |||
| 504 | + | <li data-k="file data.bin"> | |||
| 505 | + | <a href="/files/blob/main/data.bin" tabindex="-1"> | |||
| 506 | + | <span class="k">file</span>data.bin</a> | |||
| 507 | + | </li> | |||
| 508 | + | <li data-k="file empty.txt"> | |||
| 509 | + | <a href="/files/blob/main/empty.txt" tabindex="-1"> | |||
| 510 | + | <span class="k">file</span>empty.txt</a> | |||
| 511 | + | </li> | |||
| 512 | + | <li data-k="file evil.go"> | |||
| 513 | + | <a href="/files/blob/main/evil.go" tabindex="-1"> | |||
| 514 | + | <span class="k">file</span>evil.go</a> | |||
| 515 | + | </li> | |||
| 516 | + | <li data-k="file fake.jpg"> | |||
| 517 | + | <a href="/files/blob/main/fake.jpg" tabindex="-1"> | |||
| 518 | + | <span class="k">file</span>fake.jpg</a> | |||
| 519 | + | </li> | |||
| 520 | + | <li data-k="file fake.png"> | |||
| 521 | + | <a href="/files/blob/main/fake.png" tabindex="-1"> | |||
| 522 | + | <span class="k">file</span>fake.png</a> | |||
| 523 | + | </li> | |||
| 524 | + | <li data-k="file lines.txt"> | |||
| 525 | + | <a href="/files/blob/main/lines.txt" tabindex="-1"> | |||
| 526 | + | <span class="k">file</span>lines.txt</a> | |||
| 527 | + | </li> | |||
| 528 | + | <li data-k="file logo.svg"> | |||
| 529 | + | <a href="/files/blob/main/logo.svg" tabindex="-1"> | |||
| 530 | + | <span class="k">file</span>logo.svg</a> | |||
| 531 | + | </li> | |||
| 532 | + | <li data-k="file main.go"> | |||
| 533 | + | <a href="/files/blob/main/main.go" tabindex="-1"> | |||
| 534 | + | <span class="k">file</span>main.go</a> | |||
| 535 | + | </li> | |||
| 536 | + | <li data-k="file notes.txt"> | |||
| 537 | + | <a href="/files/blob/main/notes.txt" tabindex="-1"> | |||
| 538 | + | <span class="k">file</span>notes.txt</a> | |||
| 539 | + | </li> | |||
| 540 | + | <li data-k="file page.html"> | |||
| 541 | + | <a href="/files/blob/main/page.html" tabindex="-1"> | |||
| 542 | + | <span class="k">file</span>page.html</a> | |||
| 543 | + | </li> | |||
| 544 | + | <li data-k="file pic.png"> | |||
| 545 | + | <a href="/files/blob/main/pic.png" tabindex="-1"> | |||
| 546 | + | <span class="k">file</span>pic.png</a> | |||
| 547 | + | </li> | |||
| 548 | + | </ul> | |||
| 549 | + | </dialog> | |||
| 550 | + | <footer class="bar status" aria-label="Status"> | |||
| 551 | + | <span class="mode">tree</span> | |||
| 552 | + | <span class="st-ctx">tree · 15 entries</span> | |||
| 553 | + | <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'))"> | |||
| 554 | + | <span class="hi">:</span> go to…</button> | |||
| 555 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 556 | + | </span> | |||
| 557 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |||
| 558 | + | <span class="st-src"> | |||
| 559 | + | <span class="st-ver">ermite devel · </span> | |||
| 560 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 561 | + | </span> | |||
| 562 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 563 | + | <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/15</span> | |||
| 564 | + | </footer> | |||
| 565 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 566 | + | if (q('dialog[open]').count) return; | |||
| 567 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 568 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 569 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 570 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 571 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 572 | + | })(1); | |||
| 573 | + | k-k from:body -> ((d) => { | |||
| 574 | + | if (q('dialog[open]').count) return; | |||
| 575 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 576 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 577 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 578 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 579 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 580 | + | })(-1); | |||
| 581 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 582 | + | 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)); | |||
| 583 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 584 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 585 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 586 | + | 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()); | |||
| 587 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 588 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 589 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 590 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 591 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 592 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 593 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 594 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 595 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 596 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 597 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 598 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 599 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 600 | + | k-permalink from:body -> { | |||
| 601 | + | let b = document.body.dataset; | |||
| 602 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 603 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 604 | + | navigator.clipboard?.writeText(location.href); | |||
| 605 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 606 | + | }"> | |||
| 607 | + | </div> | |||
| 608 | + | </body> | |||
| 609 | + | </html> | |||
A cmd/ermite/testdata/web/tree-slash-ref.html +333 -0
| @@ -0,0 +1,333 @@ | |||
| 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>x · files · 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="feature/x" 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="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/feature/x">tree</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/feature/x">feature/x</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/tree/feature/x/x" aria-current="page">x</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/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'))">feature/x<span class="sha">@SHA7</span> | |
| 57 | + | </button> | |
| 58 | + | </header> | |
| 59 | + | <div class="work"> | |
| 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="tree-filter">filter></label> | |
| 63 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 64 | + | </div> | |
| 65 | + | <table class="list tree" aria-label="Entries"> | |
| 66 | + | <thead> | |
| 67 | + | <tr> | |
| 68 | + | <th class="cur"> | |
| 69 | + | </th> | |
| 70 | + | <th class="c-kind"> | |
| 71 | + | </th> | |
| 72 | + | <th class="c-name">name</th> | |
| 73 | + | <th class="c-size">size</th> | |
| 74 | + | <th class="c-sub">last commit</th> | |
| 75 | + | <th class="c-who">author</th> | |
| 76 | + | <th class="c-when">date</th> | |
| 77 | + | </tr> | |
| 78 | + | </thead> | |
| 79 | + | <tbody id="tree-rows" data-rows> | |
| 80 | + | <tr data-cursor> | |
| 81 | + | <td class="cur" aria-hidden="true"> | |
| 82 | + | </td> | |
| 83 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 84 | + | <td class="c-name"> | |
| 85 | + | <a href="/files/blob/feature/x/x/y.txt">y.txt</a> | |
| 86 | + | </td> | |
| 87 | + | <td class="c-size num dim">22 B</td> | |
| 88 | + | <td class="c-sub dim"> | |
| 89 | + | <a href="/files/commit/SHA40">add x/y.txt</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 | + | </main> | |
| 99 | + | </div> | |
| 100 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 101 | + | <div class="panel-head"> | |
| 102 | + | <span>keys</span> | |
| 103 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 104 | + | </div> | |
| 105 | + | <dl class="keys"> | |
| 106 | + | <div> | |
| 107 | + | <dt> | |
| 108 | + | <kbd>j</kbd> | |
| 109 | + | <kbd>k</kbd> | |
| 110 | + | </dt> | |
| 111 | + | <dd>move cursor down / up</dd> | |
| 112 | + | </div> | |
| 113 | + | <div> | |
| 114 | + | <dt> | |
| 115 | + | <kbd>enter</kbd> | |
| 116 | + | </dt> | |
| 117 | + | <dd>open row under cursor</dd> | |
| 118 | + | </div> | |
| 119 | + | <div> | |
| 120 | + | <dt> | |
| 121 | + | <kbd>g</kbd> | |
| 122 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 123 | + | </dt> | |
| 124 | + | <dd>first / last row</dd> | |
| 125 | + | </div> | |
| 126 | + | <div> | |
| 127 | + | <dt> | |
| 128 | + | <kbd>/</kbd> | |
| 129 | + | </dt> | |
| 130 | + | <dd>focus the filter</dd> | |
| 131 | + | </div> | |
| 132 | + | <div> | |
| 133 | + | <dt> | |
| 134 | + | <kbd>g h</kbd> | |
| 135 | + | <kbd>g t</kbd> | |
| 136 | + | </dt> | |
| 137 | + | <dd>go home / tree</dd> | |
| 138 | + | </div> | |
| 139 | + | <div> | |
| 140 | + | <dt> | |
| 141 | + | <kbd>g c</kbd> | |
| 142 | + | <kbd>g r</kbd> | |
| 143 | + | </dt> | |
| 144 | + | <dd>go commits / refs</dd> | |
| 145 | + | </div> | |
| 146 | + | <div> | |
| 147 | + | <dt> | |
| 148 | + | <kbd>b</kbd> | |
| 149 | + | </dt> | |
| 150 | + | <dd>switch ref (branches, tags)</dd> | |
| 151 | + | </div> | |
| 152 | + | <div> | |
| 153 | + | <dt> | |
| 154 | + | <kbd>:</kbd> | |
| 155 | + | <kbd>f</kbd> | |
| 156 | + | </dt> | |
| 157 | + | <dd>go to: section, ref, file, repo</dd> | |
| 158 | + | </div> | |
| 159 | + | <div> | |
| 160 | + | <dt> | |
| 161 | + | <kbd>y</kbd> | |
| 162 | + | </dt> | |
| 163 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 164 | + | </div> | |
| 165 | + | <div> | |
| 166 | + | <dt> | |
| 167 | + | <kbd>s</kbd> | |
| 168 | + | <kbd>t</kbd> | |
| 169 | + | <kbd>c</kbd> | |
| 170 | + | <kbd>r</kbd> | |
| 171 | + | </dt> | |
| 172 | + | <dd>repo sections (when shown)</dd> | |
| 173 | + | </div> | |
| 174 | + | <div> | |
| 175 | + | <dt> | |
| 176 | + | <kbd>d</kbd> | |
| 177 | + | <kbd>u</kbd> | |
| 178 | + | <kbd>x</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>commit: split / unified / collapse all</dd> | |
| 181 | + | </div> | |
| 182 | + | <div> | |
| 183 | + | <dt> | |
| 184 | + | <kbd>esc</kbd> | |
| 185 | + | </dt> | |
| 186 | + | <dd>close panel</dd> | |
| 187 | + | </div> | |
| 188 | + | </dl> | |
| 189 | + | </dialog> | |
| 190 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 191 | + | <div class="panel-head"> | |
| 192 | + | <label for="refs-filter">ref></label> | |
| 193 | + | <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))"> | |
| 194 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 195 | + | </div> | |
| 196 | + | <div class="panel-cols" data-picklist> | |
| 197 | + | <section aria-label="Branches"> | |
| 198 | + | <h2>branches <span class="mute">2</span> | |
| 199 | + | </h2> | |
| 200 | + | <ul> | |
| 201 | + | <li> | |
| 202 | + | <a href="/files/tree/feature/x" aria-current="true"> | |
| 203 | + | <span class="mark" aria-hidden="true">●</span>feature/x</a> | |
| 204 | + | </li> | |
| 205 | + | <li> | |
| 206 | + | <a href="/files/tree/main"> | |
| 207 | + | <span class="mark" aria-hidden="true"> </span>main</a> | |
| 208 | + | </li> | |
| 209 | + | </ul> | |
| 210 | + | </section> | |
| 211 | + | <section aria-label="Tags"> | |
| 212 | + | <h2>tags <span class="mute">1</span> | |
| 213 | + | </h2> | |
| 214 | + | <ul> | |
| 215 | + | <li> | |
| 216 | + | <a href="/files/tree/v1.0"> | |
| 217 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 218 | + | </li> | |
| 219 | + | </ul> | |
| 220 | + | </section> | |
| 221 | + | </div> | |
| 222 | + | </dialog> | |
| 223 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 224 | + | <div class="panel-head"> | |
| 225 | + | <label for="cmd-q" class="hi">:</label> | |
| 226 | + | <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(); | |
| 227 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 228 | + | 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(); | |
| 229 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 230 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 231 | + | 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' })) } | |
| 232 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 233 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 234 | + | </div> | |
| 235 | + | <ul id="cmd-list" class="cmd-list"> | |
| 236 | + | <li data-k="go home" data-cursor> | |
| 237 | + | <a href="/" tabindex="-1"> | |
| 238 | + | <span class="k">go</span>home</a> | |
| 239 | + | </li> | |
| 240 | + | <li data-k="section summary"> | |
| 241 | + | <a href="/files" tabindex="-1"> | |
| 242 | + | <span class="k">section</span>summary</a> | |
| 243 | + | </li> | |
| 244 | + | <li data-k="section tree"> | |
| 245 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 246 | + | <span class="k">section</span>tree</a> | |
| 247 | + | </li> | |
| 248 | + | <li data-k="section commits"> | |
| 249 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 250 | + | <span class="k">section</span>commits</a> | |
| 251 | + | </li> | |
| 252 | + | <li data-k="section refs"> | |
| 253 | + | <a href="/files/refs" tabindex="-1"> | |
| 254 | + | <span class="k">section</span>refs</a> | |
| 255 | + | </li> | |
| 256 | + | <li data-k="branch feature/x"> | |
| 257 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 258 | + | <span class="k">branch</span>feature/x</a> | |
| 259 | + | </li> | |
| 260 | + | <li data-k="branch main"> | |
| 261 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 262 | + | <span class="k">branch</span>main</a> | |
| 263 | + | </li> | |
| 264 | + | <li data-k="tag v1.0"> | |
| 265 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 266 | + | <span class="k">tag</span>v1.0</a> | |
| 267 | + | </li> | |
| 268 | + | <li data-k="file x/y.txt"> | |
| 269 | + | <a href="/files/blob/feature/x/x/y.txt" tabindex="-1"> | |
| 270 | + | <span class="k">file</span>x/y.txt</a> | |
| 271 | + | </li> | |
| 272 | + | </ul> | |
| 273 | + | </dialog> | |
| 274 | + | <footer class="bar status" aria-label="Status"> | |
| 275 | + | <span class="mode">tree</span> | |
| 276 | + | <span class="st-ctx">tree · 1 entry</span> | |
| 277 | + | <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'))"> | |
| 278 | + | <span class="hi">:</span> go to…</button> | |
| 279 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 280 | + | </span> | |
| 281 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |
| 282 | + | <span class="st-src"> | |
| 283 | + | <span class="st-ver">ermite devel · </span> | |
| 284 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 285 | + | </span> | |
| 286 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 287 | + | <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/1</span> | |
| 288 | + | </footer> | |
| 289 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 290 | + | if (q('dialog[open]').count) return; | |
| 291 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 292 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 293 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 294 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 295 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 296 | + | })(1); | |
| 297 | + | k-k from:body -> ((d) => { | |
| 298 | + | if (q('dialog[open]').count) return; | |
| 299 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 300 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 301 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 302 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 303 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 304 | + | })(-1); | |
| 305 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 306 | + | 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)); | |
| 307 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 308 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 309 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 310 | + | 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()); | |
| 311 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 312 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 313 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 314 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 315 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 316 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 317 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 318 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 319 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 320 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 321 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 322 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 323 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 324 | + | k-permalink from:body -> { | |
| 325 | + | let b = document.body.dataset; | |
| 326 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 327 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 328 | + | navigator.clipboard?.writeText(location.href); | |
| 329 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 330 | + | }"> | |
| 331 | + | </div> | |
| 332 | + | </body> | |
| 333 | + | </html> | |
| @@ -0,0 +1,333 @@ | |||||
| 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>x · files · 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="feature/x" 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="/files">files</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/files/tree/feature/x">tree</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/files/tree/feature/x">feature/x</a> | |||
| 34 | + | </li> | |||
| 35 | + | <li> | |||
| 36 | + | <a href="/files/tree/feature/x/x" aria-current="page">x</a> | |||
| 37 | + | </li> | |||
| 38 | + | </ol> | |||
| 39 | + | </nav> | |||
| 40 | + | <nav class="secs" aria-label="Sections"> | |||
| 41 | + | <ul> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |||
| 50 | + | </li> | |||
| 51 | + | <li> | |||
| 52 | + | <a href="/files/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'))">feature/x<span class="sha">@SHA7</span> | |||
| 57 | + | </button> | |||
| 58 | + | </header> | |||
| 59 | + | <div class="work"> | |||
| 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="tree-filter">filter></label> | |||
| 63 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |||
| 64 | + | </div> | |||
| 65 | + | <table class="list tree" aria-label="Entries"> | |||
| 66 | + | <thead> | |||
| 67 | + | <tr> | |||
| 68 | + | <th class="cur"> | |||
| 69 | + | </th> | |||
| 70 | + | <th class="c-kind"> | |||
| 71 | + | </th> | |||
| 72 | + | <th class="c-name">name</th> | |||
| 73 | + | <th class="c-size">size</th> | |||
| 74 | + | <th class="c-sub">last commit</th> | |||
| 75 | + | <th class="c-who">author</th> | |||
| 76 | + | <th class="c-when">date</th> | |||
| 77 | + | </tr> | |||
| 78 | + | </thead> | |||
| 79 | + | <tbody id="tree-rows" data-rows> | |||
| 80 | + | <tr data-cursor> | |||
| 81 | + | <td class="cur" aria-hidden="true"> | |||
| 82 | + | </td> | |||
| 83 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 84 | + | <td class="c-name"> | |||
| 85 | + | <a href="/files/blob/feature/x/x/y.txt">y.txt</a> | |||
| 86 | + | </td> | |||
| 87 | + | <td class="c-size num dim">22 B</td> | |||
| 88 | + | <td class="c-sub dim"> | |||
| 89 | + | <a href="/files/commit/SHA40">add x/y.txt</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 | + | </main> | |||
| 99 | + | </div> | |||
| 100 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 101 | + | <div class="panel-head"> | |||
| 102 | + | <span>keys</span> | |||
| 103 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 104 | + | </div> | |||
| 105 | + | <dl class="keys"> | |||
| 106 | + | <div> | |||
| 107 | + | <dt> | |||
| 108 | + | <kbd>j</kbd> | |||
| 109 | + | <kbd>k</kbd> | |||
| 110 | + | </dt> | |||
| 111 | + | <dd>move cursor down / up</dd> | |||
| 112 | + | </div> | |||
| 113 | + | <div> | |||
| 114 | + | <dt> | |||
| 115 | + | <kbd>enter</kbd> | |||
| 116 | + | </dt> | |||
| 117 | + | <dd>open row under cursor</dd> | |||
| 118 | + | </div> | |||
| 119 | + | <div> | |||
| 120 | + | <dt> | |||
| 121 | + | <kbd>g</kbd> | |||
| 122 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 123 | + | </dt> | |||
| 124 | + | <dd>first / last row</dd> | |||
| 125 | + | </div> | |||
| 126 | + | <div> | |||
| 127 | + | <dt> | |||
| 128 | + | <kbd>/</kbd> | |||
| 129 | + | </dt> | |||
| 130 | + | <dd>focus the filter</dd> | |||
| 131 | + | </div> | |||
| 132 | + | <div> | |||
| 133 | + | <dt> | |||
| 134 | + | <kbd>g h</kbd> | |||
| 135 | + | <kbd>g t</kbd> | |||
| 136 | + | </dt> | |||
| 137 | + | <dd>go home / tree</dd> | |||
| 138 | + | </div> | |||
| 139 | + | <div> | |||
| 140 | + | <dt> | |||
| 141 | + | <kbd>g c</kbd> | |||
| 142 | + | <kbd>g r</kbd> | |||
| 143 | + | </dt> | |||
| 144 | + | <dd>go commits / refs</dd> | |||
| 145 | + | </div> | |||
| 146 | + | <div> | |||
| 147 | + | <dt> | |||
| 148 | + | <kbd>b</kbd> | |||
| 149 | + | </dt> | |||
| 150 | + | <dd>switch ref (branches, tags)</dd> | |||
| 151 | + | </div> | |||
| 152 | + | <div> | |||
| 153 | + | <dt> | |||
| 154 | + | <kbd>:</kbd> | |||
| 155 | + | <kbd>f</kbd> | |||
| 156 | + | </dt> | |||
| 157 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 158 | + | </div> | |||
| 159 | + | <div> | |||
| 160 | + | <dt> | |||
| 161 | + | <kbd>y</kbd> | |||
| 162 | + | </dt> | |||
| 163 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 164 | + | </div> | |||
| 165 | + | <div> | |||
| 166 | + | <dt> | |||
| 167 | + | <kbd>s</kbd> | |||
| 168 | + | <kbd>t</kbd> | |||
| 169 | + | <kbd>c</kbd> | |||
| 170 | + | <kbd>r</kbd> | |||
| 171 | + | </dt> | |||
| 172 | + | <dd>repo sections (when shown)</dd> | |||
| 173 | + | </div> | |||
| 174 | + | <div> | |||
| 175 | + | <dt> | |||
| 176 | + | <kbd>d</kbd> | |||
| 177 | + | <kbd>u</kbd> | |||
| 178 | + | <kbd>x</kbd> | |||
| 179 | + | </dt> | |||
| 180 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 181 | + | </div> | |||
| 182 | + | <div> | |||
| 183 | + | <dt> | |||
| 184 | + | <kbd>esc</kbd> | |||
| 185 | + | </dt> | |||
| 186 | + | <dd>close panel</dd> | |||
| 187 | + | </div> | |||
| 188 | + | </dl> | |||
| 189 | + | </dialog> | |||
| 190 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 191 | + | <div class="panel-head"> | |||
| 192 | + | <label for="refs-filter">ref></label> | |||
| 193 | + | <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))"> | |||
| 194 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 195 | + | </div> | |||
| 196 | + | <div class="panel-cols" data-picklist> | |||
| 197 | + | <section aria-label="Branches"> | |||
| 198 | + | <h2>branches <span class="mute">2</span> | |||
| 199 | + | </h2> | |||
| 200 | + | <ul> | |||
| 201 | + | <li> | |||
| 202 | + | <a href="/files/tree/feature/x" aria-current="true"> | |||
| 203 | + | <span class="mark" aria-hidden="true">●</span>feature/x</a> | |||
| 204 | + | </li> | |||
| 205 | + | <li> | |||
| 206 | + | <a href="/files/tree/main"> | |||
| 207 | + | <span class="mark" aria-hidden="true"> </span>main</a> | |||
| 208 | + | </li> | |||
| 209 | + | </ul> | |||
| 210 | + | </section> | |||
| 211 | + | <section aria-label="Tags"> | |||
| 212 | + | <h2>tags <span class="mute">1</span> | |||
| 213 | + | </h2> | |||
| 214 | + | <ul> | |||
| 215 | + | <li> | |||
| 216 | + | <a href="/files/tree/v1.0"> | |||
| 217 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |||
| 218 | + | </li> | |||
| 219 | + | </ul> | |||
| 220 | + | </section> | |||
| 221 | + | </div> | |||
| 222 | + | </dialog> | |||
| 223 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 224 | + | <div class="panel-head"> | |||
| 225 | + | <label for="cmd-q" class="hi">:</label> | |||
| 226 | + | <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(); | |||
| 227 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 228 | + | 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(); | |||
| 229 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 230 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 231 | + | 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' })) } | |||
| 232 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 233 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 234 | + | </div> | |||
| 235 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 236 | + | <li data-k="go home" data-cursor> | |||
| 237 | + | <a href="/" tabindex="-1"> | |||
| 238 | + | <span class="k">go</span>home</a> | |||
| 239 | + | </li> | |||
| 240 | + | <li data-k="section summary"> | |||
| 241 | + | <a href="/files" tabindex="-1"> | |||
| 242 | + | <span class="k">section</span>summary</a> | |||
| 243 | + | </li> | |||
| 244 | + | <li data-k="section tree"> | |||
| 245 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 246 | + | <span class="k">section</span>tree</a> | |||
| 247 | + | </li> | |||
| 248 | + | <li data-k="section commits"> | |||
| 249 | + | <a href="/files/commits/main" tabindex="-1"> | |||
| 250 | + | <span class="k">section</span>commits</a> | |||
| 251 | + | </li> | |||
| 252 | + | <li data-k="section refs"> | |||
| 253 | + | <a href="/files/refs" tabindex="-1"> | |||
| 254 | + | <span class="k">section</span>refs</a> | |||
| 255 | + | </li> | |||
| 256 | + | <li data-k="branch feature/x"> | |||
| 257 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |||
| 258 | + | <span class="k">branch</span>feature/x</a> | |||
| 259 | + | </li> | |||
| 260 | + | <li data-k="branch main"> | |||
| 261 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 262 | + | <span class="k">branch</span>main</a> | |||
| 263 | + | </li> | |||
| 264 | + | <li data-k="tag v1.0"> | |||
| 265 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |||
| 266 | + | <span class="k">tag</span>v1.0</a> | |||
| 267 | + | </li> | |||
| 268 | + | <li data-k="file x/y.txt"> | |||
| 269 | + | <a href="/files/blob/feature/x/x/y.txt" tabindex="-1"> | |||
| 270 | + | <span class="k">file</span>x/y.txt</a> | |||
| 271 | + | </li> | |||
| 272 | + | </ul> | |||
| 273 | + | </dialog> | |||
| 274 | + | <footer class="bar status" aria-label="Status"> | |||
| 275 | + | <span class="mode">tree</span> | |||
| 276 | + | <span class="st-ctx">tree · 1 entry</span> | |||
| 277 | + | <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'))"> | |||
| 278 | + | <span class="hi">:</span> go to…</button> | |||
| 279 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 280 | + | </span> | |||
| 281 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |||
| 282 | + | <span class="st-src"> | |||
| 283 | + | <span class="st-ver">ermite devel · </span> | |||
| 284 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 285 | + | </span> | |||
| 286 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 287 | + | <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/1</span> | |||
| 288 | + | </footer> | |||
| 289 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 290 | + | if (q('dialog[open]').count) return; | |||
| 291 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 292 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 293 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 294 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 295 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 296 | + | })(1); | |||
| 297 | + | k-k from:body -> ((d) => { | |||
| 298 | + | if (q('dialog[open]').count) return; | |||
| 299 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 300 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 301 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 302 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 303 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 304 | + | })(-1); | |||
| 305 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 306 | + | 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)); | |||
| 307 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 308 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 309 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 310 | + | 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()); | |||
| 311 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 312 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 313 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 314 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 315 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 316 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 317 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 318 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 319 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 320 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 321 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 322 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 323 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 324 | + | k-permalink from:body -> { | |||
| 325 | + | let b = document.body.dataset; | |||
| 326 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 327 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 328 | + | navigator.clipboard?.writeText(location.href); | |||
| 329 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 330 | + | }"> | |||
| 331 | + | </div> | |||
| 332 | + | </body> | |||
| 333 | + | </html> | |||
A cmd/ermite/testdata/web/tree-subdir.html +360 -0
| @@ -0,0 +1,360 @@ | |||
| 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>docs · files · 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="/files">files</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/files/tree/main">tree</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <a href="/files/tree/main">main</a> | |
| 34 | + | </li> | |
| 35 | + | <li> | |
| 36 | + | <a href="/files/tree/main/docs" aria-current="page">docs</a> | |
| 37 | + | </li> | |
| 38 | + | </ol> | |
| 39 | + | </nav> | |
| 40 | + | <nav class="secs" aria-label="Sections"> | |
| 41 | + | <ul> | |
| 42 | + | <li> | |
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 50 | + | </li> | |
| 51 | + | <li> | |
| 52 | + | <a href="/files/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"> | |
| 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="tree-filter">filter></label> | |
| 63 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |
| 64 | + | </div> | |
| 65 | + | <table class="list tree" aria-label="Entries"> | |
| 66 | + | <thead> | |
| 67 | + | <tr> | |
| 68 | + | <th class="cur"> | |
| 69 | + | </th> | |
| 70 | + | <th class="c-kind"> | |
| 71 | + | </th> | |
| 72 | + | <th class="c-name">name</th> | |
| 73 | + | <th class="c-size">size</th> | |
| 74 | + | <th class="c-sub">last commit</th> | |
| 75 | + | <th class="c-who">author</th> | |
| 76 | + | <th class="c-when">date</th> | |
| 77 | + | </tr> | |
| 78 | + | </thead> | |
| 79 | + | <tbody id="tree-rows" data-rows> | |
| 80 | + | <tr data-cursor> | |
| 81 | + | <td class="cur" aria-hidden="true"> | |
| 82 | + | </td> | |
| 83 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 84 | + | <td class="c-name"> | |
| 85 | + | <a href="/files/blob/main/docs/README.md">README.md</a> | |
| 86 | + | </td> | |
| 87 | + | <td class="c-size num dim">62 B</td> | |
| 88 | + | <td class="c-sub dim"> | |
| 89 | + | <a href="/files/commit/SHA40">add docs/README.md</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 | + | <tr> | |
| 97 | + | <td class="cur" aria-hidden="true"> | |
| 98 | + | </td> | |
| 99 | + | <td class="c-kind" aria-hidden="true">·</td> | |
| 100 | + | <td class="c-name"> | |
| 101 | + | <a href="/files/blob/main/docs/guide.md">guide.md</a> | |
| 102 | + | </td> | |
| 103 | + | <td class="c-size num dim">47 B</td> | |
| 104 | + | <td class="c-sub dim"> | |
| 105 | + | <a href="/files/commit/SHA40">add docs/guide.md</a> | |
| 106 | + | </td> | |
| 107 | + | <td class="c-who dim">T</td> | |
| 108 | + | <td class="c-when nowrap dim"> | |
| 109 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 110 | + | </td> | |
| 111 | + | </tr> | |
| 112 | + | </tbody> | |
| 113 | + | </table> | |
| 114 | + | <h2 class="rule-h"> | |
| 115 | + | <span>README.md</span> | |
| 116 | + | </h2> | |
| 117 | + | <article class="prose" aria-label="README.md"> | |
| 118 | + | <h1 id="md-docs">Docs</h1> | |
| 119 | + | <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p> | |
| 120 | + | </article> | |
| 121 | + | </main> | |
| 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">2</span> | |
| 222 | + | </h2> | |
| 223 | + | <ul> | |
| 224 | + | <li> | |
| 225 | + | <a href="/files/tree/feature/x"> | |
| 226 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 227 | + | </li> | |
| 228 | + | <li> | |
| 229 | + | <a href="/files/tree/main" aria-current="true"> | |
| 230 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 231 | + | </li> | |
| 232 | + | </ul> | |
| 233 | + | </section> | |
| 234 | + | <section aria-label="Tags"> | |
| 235 | + | <h2>tags <span class="mute">1</span> | |
| 236 | + | </h2> | |
| 237 | + | <ul> | |
| 238 | + | <li> | |
| 239 | + | <a href="/files/tree/v1.0"> | |
| 240 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |
| 241 | + | </li> | |
| 242 | + | </ul> | |
| 243 | + | </section> | |
| 244 | + | </div> | |
| 245 | + | </dialog> | |
| 246 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 247 | + | <div class="panel-head"> | |
| 248 | + | <label for="cmd-q" class="hi">:</label> | |
| 249 | + | <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(); | |
| 250 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 251 | + | 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(); | |
| 252 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 253 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 254 | + | 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' })) } | |
| 255 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 256 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 257 | + | </div> | |
| 258 | + | <ul id="cmd-list" class="cmd-list"> | |
| 259 | + | <li data-k="go home" data-cursor> | |
| 260 | + | <a href="/" tabindex="-1"> | |
| 261 | + | <span class="k">go</span>home</a> | |
| 262 | + | </li> | |
| 263 | + | <li data-k="section summary"> | |
| 264 | + | <a href="/files" tabindex="-1"> | |
| 265 | + | <span class="k">section</span>summary</a> | |
| 266 | + | </li> | |
| 267 | + | <li data-k="section tree"> | |
| 268 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 269 | + | <span class="k">section</span>tree</a> | |
| 270 | + | </li> | |
| 271 | + | <li data-k="section commits"> | |
| 272 | + | <a href="/files/commits/main" tabindex="-1"> | |
| 273 | + | <span class="k">section</span>commits</a> | |
| 274 | + | </li> | |
| 275 | + | <li data-k="section refs"> | |
| 276 | + | <a href="/files/refs" tabindex="-1"> | |
| 277 | + | <span class="k">section</span>refs</a> | |
| 278 | + | </li> | |
| 279 | + | <li data-k="branch feature/x"> | |
| 280 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |
| 281 | + | <span class="k">branch</span>feature/x</a> | |
| 282 | + | </li> | |
| 283 | + | <li data-k="branch main"> | |
| 284 | + | <a href="/files/tree/main" tabindex="-1"> | |
| 285 | + | <span class="k">branch</span>main</a> | |
| 286 | + | </li> | |
| 287 | + | <li data-k="tag v1.0"> | |
| 288 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |
| 289 | + | <span class="k">tag</span>v1.0</a> | |
| 290 | + | </li> | |
| 291 | + | <li data-k="file docs/readme.md"> | |
| 292 | + | <a href="/files/blob/main/docs/README.md" tabindex="-1"> | |
| 293 | + | <span class="k">file</span>docs/README.md</a> | |
| 294 | + | </li> | |
| 295 | + | <li data-k="file docs/guide.md"> | |
| 296 | + | <a href="/files/blob/main/docs/guide.md" tabindex="-1"> | |
| 297 | + | <span class="k">file</span>docs/guide.md</a> | |
| 298 | + | </li> | |
| 299 | + | </ul> | |
| 300 | + | </dialog> | |
| 301 | + | <footer class="bar status" aria-label="Status"> | |
| 302 | + | <span class="mode">tree</span> | |
| 303 | + | <span class="st-ctx">tree · 2 entries</span> | |
| 304 | + | <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'))"> | |
| 305 | + | <span class="hi">:</span> go to…</button> | |
| 306 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 307 | + | </span> | |
| 308 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |
| 309 | + | <span class="st-src"> | |
| 310 | + | <span class="st-ver">ermite devel · </span> | |
| 311 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 312 | + | </span> | |
| 313 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 314 | + | <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> | |
| 315 | + | </footer> | |
| 316 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 317 | + | if (q('dialog[open]').count) return; | |
| 318 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 319 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 320 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 321 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 322 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 323 | + | })(1); | |
| 324 | + | k-k from:body -> ((d) => { | |
| 325 | + | if (q('dialog[open]').count) return; | |
| 326 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 327 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 328 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 329 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 330 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 331 | + | })(-1); | |
| 332 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 333 | + | 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)); | |
| 334 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 335 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 336 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 337 | + | 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()); | |
| 338 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 339 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 340 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 341 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 342 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 343 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 344 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 345 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 346 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 347 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 348 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 349 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 350 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 351 | + | k-permalink from:body -> { | |
| 352 | + | let b = document.body.dataset; | |
| 353 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 354 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 355 | + | navigator.clipboard?.writeText(location.href); | |
| 356 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 357 | + | }"> | |
| 358 | + | </div> | |
| 359 | + | </body> | |
| 360 | + | </html> | |
| @@ -0,0 +1,360 @@ | |||||
| 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>docs · files · 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="/files">files</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <a href="/files/tree/main">tree</a> | |||
| 31 | + | </li> | |||
| 32 | + | <li> | |||
| 33 | + | <a href="/files/tree/main">main</a> | |||
| 34 | + | </li> | |||
| 35 | + | <li> | |||
| 36 | + | <a href="/files/tree/main/docs" aria-current="page">docs</a> | |||
| 37 | + | </li> | |||
| 38 | + | </ol> | |||
| 39 | + | </nav> | |||
| 40 | + | <nav class="secs" aria-label="Sections"> | |||
| 41 | + | <ul> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/files" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/files/tree/main" data-key="t" aria-keyshortcuts="g t" aria-current="page">tree</a> | |||
| 47 | + | </li> | |||
| 48 | + | <li> | |||
| 49 | + | <a href="/files/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |||
| 50 | + | </li> | |||
| 51 | + | <li> | |||
| 52 | + | <a href="/files/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"> | |||
| 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="tree-filter">filter></label> | |||
| 63 | + | <input id="tree-filter" type="search" data-filter placeholder="this directory ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')"> | |||
| 64 | + | </div> | |||
| 65 | + | <table class="list tree" aria-label="Entries"> | |||
| 66 | + | <thead> | |||
| 67 | + | <tr> | |||
| 68 | + | <th class="cur"> | |||
| 69 | + | </th> | |||
| 70 | + | <th class="c-kind"> | |||
| 71 | + | </th> | |||
| 72 | + | <th class="c-name">name</th> | |||
| 73 | + | <th class="c-size">size</th> | |||
| 74 | + | <th class="c-sub">last commit</th> | |||
| 75 | + | <th class="c-who">author</th> | |||
| 76 | + | <th class="c-when">date</th> | |||
| 77 | + | </tr> | |||
| 78 | + | </thead> | |||
| 79 | + | <tbody id="tree-rows" data-rows> | |||
| 80 | + | <tr data-cursor> | |||
| 81 | + | <td class="cur" aria-hidden="true"> | |||
| 82 | + | </td> | |||
| 83 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 84 | + | <td class="c-name"> | |||
| 85 | + | <a href="/files/blob/main/docs/README.md">README.md</a> | |||
| 86 | + | </td> | |||
| 87 | + | <td class="c-size num dim">62 B</td> | |||
| 88 | + | <td class="c-sub dim"> | |||
| 89 | + | <a href="/files/commit/SHA40">add docs/README.md</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 | + | <tr> | |||
| 97 | + | <td class="cur" aria-hidden="true"> | |||
| 98 | + | </td> | |||
| 99 | + | <td class="c-kind" aria-hidden="true">·</td> | |||
| 100 | + | <td class="c-name"> | |||
| 101 | + | <a href="/files/blob/main/docs/guide.md">guide.md</a> | |||
| 102 | + | </td> | |||
| 103 | + | <td class="c-size num dim">47 B</td> | |||
| 104 | + | <td class="c-sub dim"> | |||
| 105 | + | <a href="/files/commit/SHA40">add docs/guide.md</a> | |||
| 106 | + | </td> | |||
| 107 | + | <td class="c-who dim">T</td> | |||
| 108 | + | <td class="c-when nowrap dim"> | |||
| 109 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 110 | + | </td> | |||
| 111 | + | </tr> | |||
| 112 | + | </tbody> | |||
| 113 | + | </table> | |||
| 114 | + | <h2 class="rule-h"> | |||
| 115 | + | <span>README.md</span> | |||
| 116 | + | </h2> | |||
| 117 | + | <article class="prose" aria-label="README.md"> | |||
| 118 | + | <h1 id="md-docs">Docs</h1> | |||
| 119 | + | <p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p> | |||
| 120 | + | </article> | |||
| 121 | + | </main> | |||
| 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">2</span> | |||
| 222 | + | </h2> | |||
| 223 | + | <ul> | |||
| 224 | + | <li> | |||
| 225 | + | <a href="/files/tree/feature/x"> | |||
| 226 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |||
| 227 | + | </li> | |||
| 228 | + | <li> | |||
| 229 | + | <a href="/files/tree/main" aria-current="true"> | |||
| 230 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 231 | + | </li> | |||
| 232 | + | </ul> | |||
| 233 | + | </section> | |||
| 234 | + | <section aria-label="Tags"> | |||
| 235 | + | <h2>tags <span class="mute">1</span> | |||
| 236 | + | </h2> | |||
| 237 | + | <ul> | |||
| 238 | + | <li> | |||
| 239 | + | <a href="/files/tree/v1.0"> | |||
| 240 | + | <span class="mark" aria-hidden="true"> </span>v1.0</a> | |||
| 241 | + | </li> | |||
| 242 | + | </ul> | |||
| 243 | + | </section> | |||
| 244 | + | </div> | |||
| 245 | + | </dialog> | |||
| 246 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 247 | + | <div class="panel-head"> | |||
| 248 | + | <label for="cmd-q" class="hi">:</label> | |||
| 249 | + | <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(); | |||
| 250 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 251 | + | 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(); | |||
| 252 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 253 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 254 | + | 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' })) } | |||
| 255 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 256 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 257 | + | </div> | |||
| 258 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 259 | + | <li data-k="go home" data-cursor> | |||
| 260 | + | <a href="/" tabindex="-1"> | |||
| 261 | + | <span class="k">go</span>home</a> | |||
| 262 | + | </li> | |||
| 263 | + | <li data-k="section summary"> | |||
| 264 | + | <a href="/files" tabindex="-1"> | |||
| 265 | + | <span class="k">section</span>summary</a> | |||
| 266 | + | </li> | |||
| 267 | + | <li data-k="section tree"> | |||
| 268 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 269 | + | <span class="k">section</span>tree</a> | |||
| 270 | + | </li> | |||
| 271 | + | <li data-k="section commits"> | |||
| 272 | + | <a href="/files/commits/main" tabindex="-1"> | |||
| 273 | + | <span class="k">section</span>commits</a> | |||
| 274 | + | </li> | |||
| 275 | + | <li data-k="section refs"> | |||
| 276 | + | <a href="/files/refs" tabindex="-1"> | |||
| 277 | + | <span class="k">section</span>refs</a> | |||
| 278 | + | </li> | |||
| 279 | + | <li data-k="branch feature/x"> | |||
| 280 | + | <a href="/files/tree/feature/x" tabindex="-1"> | |||
| 281 | + | <span class="k">branch</span>feature/x</a> | |||
| 282 | + | </li> | |||
| 283 | + | <li data-k="branch main"> | |||
| 284 | + | <a href="/files/tree/main" tabindex="-1"> | |||
| 285 | + | <span class="k">branch</span>main</a> | |||
| 286 | + | </li> | |||
| 287 | + | <li data-k="tag v1.0"> | |||
| 288 | + | <a href="/files/tree/v1.0" tabindex="-1"> | |||
| 289 | + | <span class="k">tag</span>v1.0</a> | |||
| 290 | + | </li> | |||
| 291 | + | <li data-k="file docs/readme.md"> | |||
| 292 | + | <a href="/files/blob/main/docs/README.md" tabindex="-1"> | |||
| 293 | + | <span class="k">file</span>docs/README.md</a> | |||
| 294 | + | </li> | |||
| 295 | + | <li data-k="file docs/guide.md"> | |||
| 296 | + | <a href="/files/blob/main/docs/guide.md" tabindex="-1"> | |||
| 297 | + | <span class="k">file</span>docs/guide.md</a> | |||
| 298 | + | </li> | |||
| 299 | + | </ul> | |||
| 300 | + | </dialog> | |||
| 301 | + | <footer class="bar status" aria-label="Status"> | |||
| 302 | + | <span class="mode">tree</span> | |||
| 303 | + | <span class="st-ctx">tree · 2 entries</span> | |||
| 304 | + | <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'))"> | |||
| 305 | + | <span class="hi">:</span> go to…</button> | |||
| 306 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 307 | + | </span> | |||
| 308 | + | <span class="st-hints">j/k move · enter open · / filter · b ref · ? help</span> | |||
| 309 | + | <span class="st-src"> | |||
| 310 | + | <span class="st-ver">ermite devel · </span> | |||
| 311 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 312 | + | </span> | |||
| 313 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 314 | + | <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> | |||
| 315 | + | </footer> | |||
| 316 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 317 | + | if (q('dialog[open]').count) return; | |||
| 318 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 319 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 320 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 321 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 322 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 323 | + | })(1); | |||
| 324 | + | k-k from:body -> ((d) => { | |||
| 325 | + | if (q('dialog[open]').count) return; | |||
| 326 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 327 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 328 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 329 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 330 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 331 | + | })(-1); | |||
| 332 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 333 | + | 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)); | |||
| 334 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 335 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 336 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 337 | + | 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()); | |||
| 338 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 339 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 340 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 341 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 342 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 343 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 344 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 345 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 346 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 347 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 348 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 349 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 350 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 351 | + | k-permalink from:body -> { | |||
| 352 | + | let b = document.body.dataset; | |||
| 353 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 354 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 355 | + | navigator.clipboard?.writeText(location.href); | |||
| 356 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 357 | + | }"> | |||
| 358 | + | </div> | |||
| 359 | + | </body> | |||
| 360 | + | </html> | |||
M cmd/ermite/web_test.go +1 -1
| @@ -119,7 +119,7 @@ func TestWebGolden(t *testing.T) { | |||
| 119 | 119 | {"refs", "/demo/refs", 200}, | |
| 120 | 120 | {"empty", "/quiet", 200}, | |
| 121 | 121 | {"not-found", "/nope", 404}, | |
| 122 | - | {"not-found-page", "/demo/tree/main", 404}, | |
| 122 | + | {"not-found-page", "/demo/nowhere/main", 404}, | |
| 123 | 123 | } { | |
| 124 | 124 | t.Run(tt.name, func(t *testing.T) { | |
| 125 | 125 | status, hdr, body := h.get(tt.path) | |
| @@ -119,7 +119,7 @@ func TestWebGolden(t *testing.T) { | |||||
| 119 | {"refs", "/demo/refs", 200}, | 119 | {"refs", "/demo/refs", 200}, | ||
| 120 | {"empty", "/quiet", 200}, | 120 | {"empty", "/quiet", 200}, | ||
| 121 | {"not-found", "/nope", 404}, | 121 | {"not-found", "/nope", 404}, | ||
| 122 | - | {"not-found-page", "/demo/tree/main", 404}, | 122 | + | {"not-found-page", "/demo/nowhere/main", 404}, |
| 123 | } { | 123 | } { | ||
| 124 | t.Run(tt.name, func(t *testing.T) { | 124 | t.Run(tt.name, func(t *testing.T) { | ||
| 125 | status, hdr, body := h.get(tt.path) | 125 | status, hdr, body := h.get(tt.path) | ||
M internal/adapters/web/highlight.go +91 -46
| @@ -3,11 +3,14 @@ | |||
| 3 | 3 | package web | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 6 | 8 | "html" | |
| 7 | 9 | "html/template" | |
| 8 | 10 | "path" | |
| 9 | 11 | "strconv" | |
| 10 | 12 | "strings" | |
| 13 | + | "time" | |
| 11 | 14 | "unicode" | |
| 12 | 15 | "unicode/utf8" | |
| 13 | 16 | ||
| @@ -15,10 +18,10 @@ import ( | |||
| 15 | 18 | "github.com/alecthomas/chroma/v2/lexers" | |
| 16 | 19 | ) | |
| 17 | 20 | ||
| 18 | - | // code is a source file ready for the code view: one <span class="line"> per | |
| 21 | + | // codeView is a source file ready for the code view: one <span class="line"> per | |
| 19 | 22 | // line, each with its number as an anchor, tokens as chroma CSS classes (the | |
| 20 | - | // colours are in static/css/55-chroma.css, no inline style). | |
| 21 | - | type code struct { | |
| 23 | + | // colours are in static/css/56-chroma.css, no inline style). | |
| 24 | + | type codeView struct { | |
| 22 | 25 | html template.HTML | |
| 23 | 26 | lines int | |
| 24 | 27 | // lang is the name of the language the file was highlighted as, "" for | |
| @@ -32,7 +35,7 @@ type code struct { | |||
| 32 | 35 | // never lost or altered. Characters that can mislead a reader (bidi | |
| 33 | 36 | // controls, invisible and control characters) are replaced by visible | |
| 34 | 37 | // markers. | |
| 35 | - | func highlight(name, src string) code { | |
| 38 | + | func highlight(name, src string) codeView { | |
| 36 | 39 | src = normaliseText(src) | |
| 37 | 40 | var lines [][]chroma.Token | |
| 38 | 41 | lang := "" | |
| @@ -68,9 +71,31 @@ func highlight(name, src string) code { | |||
| 68 | 71 | } | |
| 69 | 72 | // #nosec G203 -- every piece of the source goes through writeMarked, which | |
| 70 | 73 | // escapes it; the rest is markup and class names of this file. | |
| 71 | - | return code{html: template.HTML(b.String()), lines: len(lines), lang: lang} | |
| 74 | + | return codeView{html: template.HTML(b.String()), lines: len(lines), lang: lang} | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 77 | + | // highlightBudget bounds the time spent lexing one file: a lexer on hostile | |
| 78 | + | // input can be slow, and the page then shows the file as plain text. | |
| 79 | + | const highlightBudget = 10 * time.Second | |
| 80 | + | ||
| 81 | + | // highlightCtx is highlight with a time budget and the cancellation of ctx. | |
| 82 | + | // A lexer cannot be interrupted, so one that overruns is left to finish on | |
| 83 | + | // its own (its regexps have their own timeout) and its result is dropped. | |
| 84 | + | func highlightCtx(ctx context.Context, name, src string) codeView { | |
| 85 | + | done := make(chan codeView, 1) | |
| 86 | + | go func() { done <- highlight(name, src) }() | |
| 87 | + | select { | |
| 88 | + | case v := <-done: | |
| 89 | + | return v | |
| 90 | + | case <-ctx.Done(): | |
| 91 | + | case <-time.After(highlightBudget): | |
| 92 | + | } | |
| 93 | + | return highlightPlain(src) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // highlightPlain is the code view of src with no language. | |
| 97 | + | func highlightPlain(src string) codeView { return highlight("", src) } | |
| 98 | + | ||
| 74 | 99 | // maxViewLines bounds the lines of the code view: every line costs about a | |
| 75 | 100 | // hundred bytes of HTML, and a megabyte of line feeds must not become a | |
| 76 | 101 | // hundred megabytes of page. | |
| @@ -108,22 +133,17 @@ func lexerFor(name string) chroma.Lexer { | |||
| 108 | 133 | ||
| 109 | 134 | // tokenise lexes src. It reports false when the tokens do not add up to the | |
| 110 | 135 | // source, which is what a lexer that gave up half-way (its regexp timeout) | |
| 111 | - | // leaves behind. | |
| 136 | + | // leaves behind. A lexer may add a final line feed (EnsureNL), nothing else. | |
| 112 | 137 | func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) { | |
| 113 | 138 | it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src) | |
| 114 | 139 | if err != nil { | |
| 115 | 140 | return nil, false | |
| 116 | 141 | } | |
| 117 | 142 | toks := it.Tokens() | |
| 118 | - | n := 0 | |
| 119 | - | for _, t := range toks { | |
| 120 | - | n += len(t.Value) | |
| 121 | - | } | |
| 122 | - | if strings.TrimRight(src, "\n") == "" { | |
| 123 | - | return nil, n == len(src) || n == len(src)+1 | |
| 143 | + | if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") { | |
| 144 | + | return nil, false | |
| 124 | 145 | } | |
| 125 | - | // A lexer may add a final line feed (EnsureNL) but nothing else. | |
| 126 | - | return toks, len(strings.TrimRight(joinTokens(toks), "\n")) == len(strings.TrimRight(src, "\n")) | |
| 146 | + | return toks, true | |
| 127 | 147 | } | |
| 128 | 148 | ||
| 129 | 149 | func joinTokens(toks []chroma.Token) string { | |
| @@ -159,13 +179,14 @@ func tokenClass(t chroma.TokenType) string { | |||
| 159 | 179 | return chroma.StandardTypes[t] | |
| 160 | 180 | } | |
| 161 | 181 | ||
| 162 | - | // misleading are the characters, other than the controls, that the code | |
| 163 | - | // view shows as markers instead of itself: the bidirectional controls of | |
| 164 | - | // "Trojan Source" attacks (CVE-2021-42574) and the characters with no | |
| 165 | - | // glyph. | |
| 166 | - | var misleading = map[rune]string{ | |
| 182 | + | // invisibleNames name the characters of the code view that are shown as | |
| 183 | + | // markers, for the tooltip. Any other invisible character is called by its | |
| 184 | + | // category. | |
| 185 | + | var invisibleNames = map[rune]string{ | |
| 167 | 186 | 0x061C: "arabic letter mark", | |
| 168 | 187 | 0x200B: "zero width space", | |
| 188 | + | 0x200C: "zero width non-joiner", | |
| 189 | + | 0x200D: "zero width joiner", | |
| 169 | 190 | 0x200E: "left-to-right mark", | |
| 170 | 191 | 0x200F: "right-to-left mark", | |
| 171 | 192 | 0x202A: "left-to-right embedding", | |
| @@ -173,51 +194,75 @@ var misleading = map[rune]string{ | |||
| 173 | 194 | 0x202C: "pop directional formatting", | |
| 174 | 195 | 0x202D: "left-to-right override", | |
| 175 | 196 | 0x202E: "right-to-left override", | |
| 197 | + | 0x2028: "line separator", | |
| 198 | + | 0x2029: "paragraph separator", | |
| 176 | 199 | 0x2060: "word joiner", | |
| 177 | 200 | 0x2066: "left-to-right isolate", | |
| 178 | 201 | 0x2067: "right-to-left isolate", | |
| 179 | 202 | 0x2068: "first strong isolate", | |
| 180 | 203 | 0x2069: "pop directional isolate", | |
| 181 | - | 0xFEFF: "zero width no-break space (byte order mark)", | |
| 204 | + | 0xFEFF: "byte order mark", | |
| 182 | 205 | } | |
| 183 | 206 | ||
| 184 | - | // markerName says whether r is shown as a marker, and how it is called. | |
| 185 | - | // Tab is ordinary; the line feed never reaches here. | |
| 186 | - | func markerName(r rune) (string, bool) { | |
| 187 | - | if n, ok := misleading[r]; ok { | |
| 188 | - | return n, true | |
| 189 | - | } | |
| 190 | - | switch { | |
| 191 | - | case r == '\t': | |
| 192 | - | return "", false | |
| 193 | - | case r == 0x2028 || r == 0x2029: | |
| 194 | - | return "line or paragraph separator", true | |
| 195 | - | case unicode.IsControl(r): | |
| 196 | - | return "control character", true | |
| 197 | - | } | |
| 198 | - | return "", false | |
| 207 | + | // isInvisible reports whether r changes how code reads without being seen: | |
| 208 | + | // the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and | |
| 209 | + | // the other format characters, the controls, the line and paragraph | |
| 210 | + | // separators and the blank Hangul fillers. A tab is ordinary. | |
| 211 | + | func isInvisible(r rune) bool { | |
| 212 | + | switch r { | |
| 213 | + | case '\t', '\n': | |
| 214 | + | return false | |
| 215 | + | case 0x115F, 0x1160, 0x3164, 0xFFA0: | |
| 216 | + | return true | |
| 217 | + | } | |
| 218 | + | return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) | |
| 199 | 219 | } | |
| 200 | 220 | ||
| 201 | - | func isMarked(r rune) bool { _, ok := markerName(r); return ok } | |
| 221 | + | // codePoint is "U+202E". | |
| 222 | + | func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) } | |
| 202 | 223 | ||
| 203 | - | // writeMarked writes s escaped for HTML, with each misleading character | |
| 204 | - | // replaced by <span class="ctl" title="U+202E right-to-left override"> | |
| 205 | - | // <U+202E></span>. The character itself is not output. | |
| 224 | + | // invisibleName is what the tooltip of a marker says. | |
| 225 | + | func invisibleName(r rune) string { | |
| 226 | + | if n, ok := invisibleNames[r]; ok { | |
| 227 | + | return n | |
| 228 | + | } | |
| 229 | + | if unicode.IsControl(r) { | |
| 230 | + | return "control character" | |
| 231 | + | } | |
| 232 | + | return "invisible character" | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // writeMarked writes s escaped for HTML, each invisible character replaced | |
| 236 | + | // by <span class="ctl" title="U+202E right-to-left override"><U+202E> | |
| 237 | + | // </span>. The character itself is never output. | |
| 206 | 238 | func writeMarked(b *strings.Builder, s string) { | |
| 207 | 239 | for { | |
| 208 | - | i := strings.IndexFunc(s, isMarked) | |
| 240 | + | i := strings.IndexFunc(s, isInvisible) | |
| 209 | 241 | if i < 0 { | |
| 210 | 242 | b.WriteString(html.EscapeString(s)) | |
| 211 | 243 | return | |
| 212 | 244 | } | |
| 213 | 245 | b.WriteString(html.EscapeString(s[:i])) | |
| 214 | 246 | r, size := utf8.DecodeRuneInString(s[i:]) | |
| 215 | - | name, _ := markerName(r) | |
| 216 | - | code := "U+" + strings.ToUpper(strconv.FormatInt(int64(r)+0x10000, 16)[1:]) | |
| 217 | - | if r > 0xFFFF { | |
| 218 | - | code = "U+" + strings.ToUpper(strconv.FormatInt(int64(r), 16)) | |
| 219 | - | } | |
| 220 | - | b.WriteString(`<span class="ctl" title="` + code + ` ` + name + `"><` + code + `></span>`) | |
| 247 | + | cp := codePoint(r) | |
| 248 | + | b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `"><` + cp + `></span>`) | |
| 221 | 249 | s = s[i+size:] | |
| 222 | 250 | } | |
| 223 | 251 | } | |
| 252 | + | ||
| 253 | + | // markedText is s as plain text with each invisible character replaced by | |
| 254 | + | // its <U+XXXX> marker, for names and paths shown outside the code view. | |
| 255 | + | func markedText(s string) string { | |
| 256 | + | if !strings.ContainsFunc(s, isInvisible) { | |
| 257 | + | return s | |
| 258 | + | } | |
| 259 | + | var b strings.Builder | |
| 260 | + | for _, r := range s { | |
| 261 | + | if isInvisible(r) { | |
| 262 | + | b.WriteString("<" + codePoint(r) + ">") | |
| 263 | + | } else { | |
| 264 | + | b.WriteRune(r) | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | return b.String() | |
| 268 | + | } | |
| @@ -3,11 +3,14 @@ | |||||
| 3 | package web | 3 | package web | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 7 | + | "fmt" | |||
| 6 | "html" | 8 | "html" | ||
| 7 | "html/template" | 9 | "html/template" | ||
| 8 | "path" | 10 | "path" | ||
| 9 | "strconv" | 11 | "strconv" | ||
| 10 | "strings" | 12 | "strings" | ||
| 13 | + | "time" | |||
| 11 | "unicode" | 14 | "unicode" | ||
| 12 | "unicode/utf8" | 15 | "unicode/utf8" | ||
| 13 | 16 | ||||
| @@ -15,10 +18,10 @@ import ( | |||||
| 15 | "github.com/alecthomas/chroma/v2/lexers" | 18 | "github.com/alecthomas/chroma/v2/lexers" | ||
| 16 | ) | 19 | ) | ||
| 17 | 20 | ||||
| 18 | - | // code is a source file ready for the code view: one <span class="line"> per | 21 | + | // codeView is a source file ready for the code view: one <span class="line"> per |
| 19 | // line, each with its number as an anchor, tokens as chroma CSS classes (the | 22 | // line, each with its number as an anchor, tokens as chroma CSS classes (the | ||
| 20 | - | // colours are in static/css/55-chroma.css, no inline style). | 23 | + | // colours are in static/css/56-chroma.css, no inline style). |
| 21 | - | type code struct { | 24 | + | type codeView struct { |
| 22 | html template.HTML | 25 | html template.HTML | ||
| 23 | lines int | 26 | lines int | ||
| 24 | // lang is the name of the language the file was highlighted as, "" for | 27 | // lang is the name of the language the file was highlighted as, "" for | ||
| @@ -32,7 +35,7 @@ type code struct { | |||||
| 32 | // never lost or altered. Characters that can mislead a reader (bidi | 35 | // never lost or altered. Characters that can mislead a reader (bidi | ||
| 33 | // controls, invisible and control characters) are replaced by visible | 36 | // controls, invisible and control characters) are replaced by visible | ||
| 34 | // markers. | 37 | // markers. | ||
| 35 | - | func highlight(name, src string) code { | 38 | + | func highlight(name, src string) codeView { |
| 36 | src = normaliseText(src) | 39 | src = normaliseText(src) | ||
| 37 | var lines [][]chroma.Token | 40 | var lines [][]chroma.Token | ||
| 38 | lang := "" | 41 | lang := "" | ||
| @@ -68,9 +71,31 @@ func highlight(name, src string) code { | |||||
| 68 | } | 71 | } | ||
| 69 | // #nosec G203 -- every piece of the source goes through writeMarked, which | 72 | // #nosec G203 -- every piece of the source goes through writeMarked, which | ||
| 70 | // escapes it; the rest is markup and class names of this file. | 73 | // escapes it; the rest is markup and class names of this file. | ||
| 71 | - | return code{html: template.HTML(b.String()), lines: len(lines), lang: lang} | 74 | + | return codeView{html: template.HTML(b.String()), lines: len(lines), lang: lang} |
| 72 | } | 75 | } | ||
| 73 | 76 | ||||
| 77 | + | // highlightBudget bounds the time spent lexing one file: a lexer on hostile | |||
| 78 | + | // input can be slow, and the page then shows the file as plain text. | |||
| 79 | + | const highlightBudget = 10 * time.Second | |||
| 80 | + | ||||
| 81 | + | // highlightCtx is highlight with a time budget and the cancellation of ctx. | |||
| 82 | + | // A lexer cannot be interrupted, so one that overruns is left to finish on | |||
| 83 | + | // its own (its regexps have their own timeout) and its result is dropped. | |||
| 84 | + | func highlightCtx(ctx context.Context, name, src string) codeView { | |||
| 85 | + | done := make(chan codeView, 1) | |||
| 86 | + | go func() { done <- highlight(name, src) }() | |||
| 87 | + | select { | |||
| 88 | + | case v := <-done: | |||
| 89 | + | return v | |||
| 90 | + | case <-ctx.Done(): | |||
| 91 | + | case <-time.After(highlightBudget): | |||
| 92 | + | } | |||
| 93 | + | return highlightPlain(src) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // highlightPlain is the code view of src with no language. | |||
| 97 | + | func highlightPlain(src string) codeView { return highlight("", src) } | |||
| 98 | + | ||||
| 74 | // maxViewLines bounds the lines of the code view: every line costs about a | 99 | // maxViewLines bounds the lines of the code view: every line costs about a | ||
| 75 | // hundred bytes of HTML, and a megabyte of line feeds must not become a | 100 | // hundred bytes of HTML, and a megabyte of line feeds must not become a | ||
| 76 | // hundred megabytes of page. | 101 | // hundred megabytes of page. | ||
| @@ -108,22 +133,17 @@ func lexerFor(name string) chroma.Lexer { | |||||
| 108 | 133 | ||||
| 109 | // tokenise lexes src. It reports false when the tokens do not add up to the | 134 | // tokenise lexes src. It reports false when the tokens do not add up to the | ||
| 110 | // source, which is what a lexer that gave up half-way (its regexp timeout) | 135 | // source, which is what a lexer that gave up half-way (its regexp timeout) | ||
| 111 | - | // leaves behind. | 136 | + | // leaves behind. A lexer may add a final line feed (EnsureNL), nothing else. |
| 112 | func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) { | 137 | func tokenise(lx chroma.Lexer, src string) ([]chroma.Token, bool) { | ||
| 113 | it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src) | 138 | it, err := lx.Tokenise(&chroma.TokeniseOptions{State: "root", EnsureLF: true}, src) | ||
| 114 | if err != nil { | 139 | if err != nil { | ||
| 115 | return nil, false | 140 | return nil, false | ||
| 116 | } | 141 | } | ||
| 117 | toks := it.Tokens() | 142 | toks := it.Tokens() | ||
| 118 | - | n := 0 | 143 | + | if strings.TrimRight(joinTokens(toks), "\n") != strings.TrimRight(src, "\n") { |
| 119 | - | for _, t := range toks { | 144 | + | return nil, false |
| 120 | - | n += len(t.Value) | |||
| 121 | - | } | |||
| 122 | - | if strings.TrimRight(src, "\n") == "" { | |||
| 123 | - | return nil, n == len(src) || n == len(src)+1 | |||
| 124 | } | 145 | } | ||
| 125 | - | // A lexer may add a final line feed (EnsureNL) but nothing else. | 146 | + | return toks, true |
| 126 | - | return toks, len(strings.TrimRight(joinTokens(toks), "\n")) == len(strings.TrimRight(src, "\n")) | |||
| 127 | } | 147 | } | ||
| 128 | 148 | ||||
| 129 | func joinTokens(toks []chroma.Token) string { | 149 | func joinTokens(toks []chroma.Token) string { | ||
| @@ -159,13 +179,14 @@ func tokenClass(t chroma.TokenType) string { | |||||
| 159 | return chroma.StandardTypes[t] | 179 | return chroma.StandardTypes[t] | ||
| 160 | } | 180 | } | ||
| 161 | 181 | ||||
| 162 | - | // misleading are the characters, other than the controls, that the code | 182 | + | // invisibleNames name the characters of the code view that are shown as |
| 163 | - | // view shows as markers instead of itself: the bidirectional controls of | 183 | + | // markers, for the tooltip. Any other invisible character is called by its |
| 164 | - | // "Trojan Source" attacks (CVE-2021-42574) and the characters with no | 184 | + | // category. |
| 165 | - | // glyph. | 185 | + | var invisibleNames = map[rune]string{ |
| 166 | - | var misleading = map[rune]string{ | |||
| 167 | 0x061C: "arabic letter mark", | 186 | 0x061C: "arabic letter mark", | ||
| 168 | 0x200B: "zero width space", | 187 | 0x200B: "zero width space", | ||
| 188 | + | 0x200C: "zero width non-joiner", | |||
| 189 | + | 0x200D: "zero width joiner", | |||
| 169 | 0x200E: "left-to-right mark", | 190 | 0x200E: "left-to-right mark", | ||
| 170 | 0x200F: "right-to-left mark", | 191 | 0x200F: "right-to-left mark", | ||
| 171 | 0x202A: "left-to-right embedding", | 192 | 0x202A: "left-to-right embedding", | ||
| @@ -173,51 +194,75 @@ var misleading = map[rune]string{ | |||||
| 173 | 0x202C: "pop directional formatting", | 194 | 0x202C: "pop directional formatting", | ||
| 174 | 0x202D: "left-to-right override", | 195 | 0x202D: "left-to-right override", | ||
| 175 | 0x202E: "right-to-left override", | 196 | 0x202E: "right-to-left override", | ||
| 197 | + | 0x2028: "line separator", | |||
| 198 | + | 0x2029: "paragraph separator", | |||
| 176 | 0x2060: "word joiner", | 199 | 0x2060: "word joiner", | ||
| 177 | 0x2066: "left-to-right isolate", | 200 | 0x2066: "left-to-right isolate", | ||
| 178 | 0x2067: "right-to-left isolate", | 201 | 0x2067: "right-to-left isolate", | ||
| 179 | 0x2068: "first strong isolate", | 202 | 0x2068: "first strong isolate", | ||
| 180 | 0x2069: "pop directional isolate", | 203 | 0x2069: "pop directional isolate", | ||
| 181 | - | 0xFEFF: "zero width no-break space (byte order mark)", | 204 | + | 0xFEFF: "byte order mark", |
| 182 | } | 205 | } | ||
| 183 | 206 | ||||
| 184 | - | // markerName says whether r is shown as a marker, and how it is called. | 207 | + | // isInvisible reports whether r changes how code reads without being seen: |
| 185 | - | // Tab is ordinary; the line feed never reaches here. | 208 | + | // the bidirectional controls of "Trojan Source" attacks (CVE-2021-42574) and |
| 186 | - | func markerName(r rune) (string, bool) { | 209 | + | // the other format characters, the controls, the line and paragraph |
| 187 | - | if n, ok := misleading[r]; ok { | 210 | + | // separators and the blank Hangul fillers. A tab is ordinary. |
| 188 | - | return n, true | 211 | + | func isInvisible(r rune) bool { |
| 189 | - | } | 212 | + | switch r { |
| 190 | - | switch { | 213 | + | case '\t', '\n': |
| 191 | - | case r == '\t': | 214 | + | return false |
| 192 | - | return "", false | 215 | + | case 0x115F, 0x1160, 0x3164, 0xFFA0: |
| 193 | - | case r == 0x2028 || r == 0x2029: | 216 | + | return true |
| 194 | - | return "line or paragraph separator", true | 217 | + | } |
| 195 | - | case unicode.IsControl(r): | 218 | + | return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) |
| 196 | - | return "control character", true | |||
| 197 | - | } | |||
| 198 | - | return "", false | |||
| 199 | } | 219 | } | ||
| 200 | 220 | ||||
| 201 | - | func isMarked(r rune) bool { _, ok := markerName(r); return ok } | 221 | + | // codePoint is "U+202E". |
| 222 | + | func codePoint(r rune) string { return fmt.Sprintf("U+%04X", r) } | |||
| 202 | 223 | ||||
| 203 | - | // writeMarked writes s escaped for HTML, with each misleading character | 224 | + | // invisibleName is what the tooltip of a marker says. |
| 204 | - | // replaced by <span class="ctl" title="U+202E right-to-left override"> | 225 | + | func invisibleName(r rune) string { |
| 205 | - | // <U+202E></span>. The character itself is not output. | 226 | + | if n, ok := invisibleNames[r]; ok { |
| 227 | + | return n | |||
| 228 | + | } | |||
| 229 | + | if unicode.IsControl(r) { | |||
| 230 | + | return "control character" | |||
| 231 | + | } | |||
| 232 | + | return "invisible character" | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | // writeMarked writes s escaped for HTML, each invisible character replaced | |||
| 236 | + | // by <span class="ctl" title="U+202E right-to-left override"><U+202E> | |||
| 237 | + | // </span>. The character itself is never output. | |||
| 206 | func writeMarked(b *strings.Builder, s string) { | 238 | func writeMarked(b *strings.Builder, s string) { | ||
| 207 | for { | 239 | for { | ||
| 208 | - | i := strings.IndexFunc(s, isMarked) | 240 | + | i := strings.IndexFunc(s, isInvisible) |
| 209 | if i < 0 { | 241 | if i < 0 { | ||
| 210 | b.WriteString(html.EscapeString(s)) | 242 | b.WriteString(html.EscapeString(s)) | ||
| 211 | return | 243 | return | ||
| 212 | } | 244 | } | ||
| 213 | b.WriteString(html.EscapeString(s[:i])) | 245 | b.WriteString(html.EscapeString(s[:i])) | ||
| 214 | r, size := utf8.DecodeRuneInString(s[i:]) | 246 | r, size := utf8.DecodeRuneInString(s[i:]) | ||
| 215 | - | name, _ := markerName(r) | 247 | + | cp := codePoint(r) |
| 216 | - | code := "U+" + strings.ToUpper(strconv.FormatInt(int64(r)+0x10000, 16)[1:]) | 248 | + | b.WriteString(`<span class="ctl" title="` + cp + ` ` + invisibleName(r) + `"><` + cp + `></span>`) |
| 217 | - | if r > 0xFFFF { | |||
| 218 | - | code = "U+" + strings.ToUpper(strconv.FormatInt(int64(r), 16)) | |||
| 219 | - | } | |||
| 220 | - | b.WriteString(`<span class="ctl" title="` + code + ` ` + name + `"><` + code + `></span>`) | |||
| 221 | s = s[i+size:] | 249 | s = s[i+size:] | ||
| 222 | } | 250 | } | ||
| 223 | } | 251 | } | ||
| 252 | + | ||||
| 253 | + | // markedText is s as plain text with each invisible character replaced by | |||
| 254 | + | // its <U+XXXX> marker, for names and paths shown outside the code view. | |||
| 255 | + | func markedText(s string) string { | |||
| 256 | + | if !strings.ContainsFunc(s, isInvisible) { | |||
| 257 | + | return s | |||
| 258 | + | } | |||
| 259 | + | var b strings.Builder | |||
| 260 | + | for _, r := range s { | |||
| 261 | + | if isInvisible(r) { | |||
| 262 | + | b.WriteString("<" + codePoint(r) + ">") | |||
| 263 | + | } else { | |||
| 264 | + | b.WriteRune(r) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | return b.String() | |||
| 268 | + | } | |||
A internal/adapters/web/tree.go +220 -0
| @@ -0,0 +1,220 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "connectrpc.com/connect" | |
| 11 | + | "github.com/a-h/templ" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // target is what the URL of a tree, blob or raw page designates: a commit, | |
| 17 | + | // the name it was reached by, and a path in its tree. | |
| 18 | + | type target struct { | |
| 19 | + | // rev is what the API is asked for: the id of the commit of a ref, or | |
| 20 | + | // the commit id of the URL itself. | |
| 21 | + | rev string | |
| 22 | + | // name is how the URL spells the ref: a branch or a tag, or the commit id | |
| 23 | + | // when detached. | |
| 24 | + | name string | |
| 25 | + | path string | |
| 26 | + | // detached is true when the URL names a commit and not a ref. | |
| 27 | + | detached bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | |
| 31 | + | // longest prefix of the path that is the short name of a branch or a tag | |
| 32 | + | // (names may contain "/"); a branch wins over a tag of the same name. An | |
| 33 | + | // empty rest designates the head of the repository. Whether the commit or | |
| 34 | + | // the path exists is for the API to say. | |
| 35 | + | func (d *repoData) locate(rest string) (target, bool) { | |
| 36 | + | rest = strings.Trim(rest, "/") | |
| 37 | + | if rest == "" { | |
| 38 | + | if d.head == nil { | |
| 39 | + | return target{}, false | |
| 40 | + | } | |
| 41 | + | return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | |
| 42 | + | } | |
| 43 | + | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |
| 44 | + | if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" { | |
| 45 | + | return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | |
| 46 | + | } | |
| 47 | + | first, p, _ := strings.Cut(rest, "/") | |
| 48 | + | if looksLikeCommit(first) { | |
| 49 | + | return target{rev: first, name: first, path: p, detached: true}, true | |
| 50 | + | } | |
| 51 | + | return target{}, false | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // looksLikeCommit reports whether s can be a commit id or an abbreviation of | |
| 55 | + | // one (7 to 40 lowercase hexadecimal digits). | |
| 56 | + | func looksLikeCommit(s string) bool { | |
| 57 | + | if len(s) < 7 || len(s) > 40 { | |
| 58 | + | return false | |
| 59 | + | } | |
| 60 | + | for _, c := range s { | |
| 61 | + | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |
| 62 | + | return false | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return true | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // pinRef points the ref switcher of the layout at the ref a page shows. | |
| 69 | + | func pinRef(l *Layout, t target) { | |
| 70 | + | if t.detached { | |
| 71 | + | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), "" | |
| 72 | + | if len(t.rev) == 40 { | |
| 73 | + | l.SHA = t.rev | |
| 74 | + | } | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev) | |
| 78 | + | for i := range l.Heads { | |
| 79 | + | l.Heads[i].Current = l.Heads[i].Name == t.name | |
| 80 | + | } | |
| 81 | + | for i := range l.Tags { | |
| 82 | + | l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func hasName(items []RefItem, name string) bool { | |
| 87 | + | for _, it := range items { | |
| 88 | + | if it.Name == name { | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // fileCrumbs is the path in the top bar: repo, kind ("tree" or "blob"), ref, | |
| 96 | + | // then the segments of the path. The directories link to their tree. | |
| 97 | + | func fileCrumbs(repo, kind string, t target) []Crumb { | |
| 98 | + | root := repoHref(repo, "tree", t.name) | |
| 99 | + | cs := []Crumb{ | |
| 100 | + | {Label: repo, Href: repoHref(repo)}, | |
| 101 | + | {Label: kind, Href: root}, | |
| 102 | + | {Label: t.name, Href: root, Current: t.path == ""}, | |
| 103 | + | } | |
| 104 | + | if t.path == "" { | |
| 105 | + | return cs | |
| 106 | + | } | |
| 107 | + | segs := strings.Split(t.path, "/") | |
| 108 | + | for i, seg := range segs { | |
| 109 | + | last := i == len(segs)-1 | |
| 110 | + | c := Crumb{Label: markedText(seg), Current: last} | |
| 111 | + | if !last || kind == "tree" { | |
| 112 | + | c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/")) | |
| 113 | + | } else { | |
| 114 | + | c.Href = repoHref(repo, "blob", t.name, t.path) | |
| 115 | + | } | |
| 116 | + | cs = append(cs, c) | |
| 117 | + | } | |
| 118 | + | return cs | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | // maxFileTargets bounds the files of a directory offered in the ":" list. | |
| 122 | + | const maxFileTargets = 200 | |
| 123 | + | ||
| 124 | + | // TreeRow is one entry of a directory. | |
| 125 | + | type TreeRow struct { | |
| 126 | + | Glyph string // ▾ directory, @ symbolic link, + submodule, · file | |
| 127 | + | Name string | |
| 128 | + | Href string // "" for a submodule | |
| 129 | + | Size string | |
| 130 | + | Subject string | |
| 131 | + | CHref string | |
| 132 | + | Author string | |
| 133 | + | When string | |
| 134 | + | WhenAt string | |
| 135 | + | Cursor bool | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // TreePage is a directory. | |
| 139 | + | type TreePage struct { | |
| 140 | + | Rows []TreeRow | |
| 141 | + | Readme *Readme | |
| 142 | + | // Notes say what the listing leaves out. | |
| 143 | + | Notes []string | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // tree lists a directory of a ref, with the last commit of each entry, and | |
| 147 | + | // renders the README of the directory below. | |
| 148 | + | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 149 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, 0, err | |
| 152 | + | } | |
| 153 | + | t, ok := d.locate(r.PathValue("rest")) | |
| 154 | + | if !ok { | |
| 155 | + | return nil, 0, errNotFound | |
| 156 | + | } | |
| 157 | + | res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{ | |
| 158 | + | Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true, | |
| 159 | + | })) | |
| 160 | + | if err != nil { | |
| 161 | + | return nil, 0, err | |
| 162 | + | } | |
| 163 | + | entries := res.Msg.GetEntries() | |
| 164 | + | now := s.cfg.Now() | |
| 165 | + | ||
| 166 | + | p := TreePage{} | |
| 167 | + | l := s.repoLayout(ctx, d, "tree") | |
| 168 | + | for i, e := range entries { | |
| 169 | + | row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | |
| 170 | + | switch e.GetKind() { | |
| 171 | + | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | |
| 172 | + | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | |
| 173 | + | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | |
| 174 | + | case pb.EntryKind_ENTRY_KIND_SYMLINK: | |
| 175 | + | row.Glyph = "@" | |
| 176 | + | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |
| 177 | + | case pb.EntryKind_ENTRY_KIND_SUBMODULE: | |
| 178 | + | row.Glyph, row.Size = "+", "-" | |
| 179 | + | default: | |
| 180 | + | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |
| 181 | + | if len(l.Cmd) < maxFileTargets { | |
| 182 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href}) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | if c := e.GetLastCommit(); c != nil { | |
| 186 | + | row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName() | |
| 187 | + | row.CHref = repoHref(d.name(), "commit", c.GetId()) | |
| 188 | + | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | |
| 189 | + | row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | p.Rows = append(p.Rows, row) | |
| 193 | + | } | |
| 194 | + | if res.Msg.GetTruncated() { | |
| 195 | + | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | |
| 196 | + | } | |
| 197 | + | if res.Msg.GetLastCommitsTruncated() { | |
| 198 | + | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | |
| 199 | + | } | |
| 200 | + | rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries) | |
| 201 | + | if err != nil { | |
| 202 | + | return nil, 0, err | |
| 203 | + | } | |
| 204 | + | if rd != nil { | |
| 205 | + | p.Readme = rd.readme | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | pinRef(&l, t) | |
| 209 | + | l.Crumbs = fileCrumbs(d.name(), "tree", t) | |
| 210 | + | if t.path == "" { | |
| 211 | + | l.Title = "tree · " + d.name() + " · " + s.instance | |
| 212 | + | } else { | |
| 213 | + | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | |
| 214 | + | } | |
| 215 | + | l.Status = Status{ | |
| 216 | + | Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"), | |
| 217 | + | Hints: "j/k move · enter open · / filter · b ref · ? help", Cursor: true, Count: len(p.Rows), | |
| 218 | + | } | |
| 219 | + | return Tree(l, p), http.StatusOK, nil | |
| 220 | + | } | |
| @@ -0,0 +1,220 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "net/http" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "connectrpc.com/connect" | |||
| 11 | + | "github.com/a-h/templ" | |||
| 12 | + | ||||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // target is what the URL of a tree, blob or raw page designates: a commit, | |||
| 17 | + | // the name it was reached by, and a path in its tree. | |||
| 18 | + | type target struct { | |||
| 19 | + | // rev is what the API is asked for: the id of the commit of a ref, or | |||
| 20 | + | // the commit id of the URL itself. | |||
| 21 | + | rev string | |||
| 22 | + | // name is how the URL spells the ref: a branch or a tag, or the commit id | |||
| 23 | + | // when detached. | |||
| 24 | + | name string | |||
| 25 | + | path string | |||
| 26 | + | // detached is true when the URL names a commit and not a ref. | |||
| 27 | + | detached bool | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // locate reads "<ref>[/<path>]" or "<commit>[/<path>]". The ref is the | |||
| 31 | + | // longest prefix of the path that is the short name of a branch or a tag | |||
| 32 | + | // (names may contain "/"); a branch wins over a tag of the same name. An | |||
| 33 | + | // empty rest designates the head of the repository. Whether the commit or | |||
| 34 | + | // the path exists is for the API to say. | |||
| 35 | + | func (d *repoData) locate(rest string) (target, bool) { | |||
| 36 | + | rest = strings.Trim(rest, "/") | |||
| 37 | + | if rest == "" { | |||
| 38 | + | if d.head == nil { | |||
| 39 | + | return target{}, false | |||
| 40 | + | } | |||
| 41 | + | return target{rev: d.head.GetCommitId(), name: d.head.GetShortName()}, true | |||
| 42 | + | } | |||
| 43 | + | all := append(append([]*pb.Ref(nil), d.branches...), d.tags...) | |||
| 44 | + | if ref, p, ok := resolveRef(all, rest); ok && ref.GetCommitId() != "" { | |||
| 45 | + | return target{rev: ref.GetCommitId(), name: ref.GetShortName(), path: p}, true | |||
| 46 | + | } | |||
| 47 | + | first, p, _ := strings.Cut(rest, "/") | |||
| 48 | + | if looksLikeCommit(first) { | |||
| 49 | + | return target{rev: first, name: first, path: p, detached: true}, true | |||
| 50 | + | } | |||
| 51 | + | return target{}, false | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // looksLikeCommit reports whether s can be a commit id or an abbreviation of | |||
| 55 | + | // one (7 to 40 lowercase hexadecimal digits). | |||
| 56 | + | func looksLikeCommit(s string) bool { | |||
| 57 | + | if len(s) < 7 || len(s) > 40 { | |||
| 58 | + | return false | |||
| 59 | + | } | |||
| 60 | + | for _, c := range s { | |||
| 61 | + | if (c < '0' || c > '9') && (c < 'a' || c > 'f') { | |||
| 62 | + | return false | |||
| 63 | + | } | |||
| 64 | + | } | |||
| 65 | + | return true | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // pinRef points the ref switcher of the layout at the ref a page shows. | |||
| 69 | + | func pinRef(l *Layout, t target) { | |||
| 70 | + | if t.detached { | |||
| 71 | + | l.Ref, l.RefPath, l.Short, l.SHA = "detached", "", short(t.rev), "" | |||
| 72 | + | if len(t.rev) == 40 { | |||
| 73 | + | l.SHA = t.rev | |||
| 74 | + | } | |||
| 75 | + | return | |||
| 76 | + | } | |||
| 77 | + | l.Ref, l.RefPath, l.SHA, l.Short = t.name, t.name, t.rev, short(t.rev) | |||
| 78 | + | for i := range l.Heads { | |||
| 79 | + | l.Heads[i].Current = l.Heads[i].Name == t.name | |||
| 80 | + | } | |||
| 81 | + | for i := range l.Tags { | |||
| 82 | + | l.Tags[i].Current = l.Tags[i].Name == t.name && !hasName(l.Heads, t.name) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func hasName(items []RefItem, name string) bool { | |||
| 87 | + | for _, it := range items { | |||
| 88 | + | if it.Name == name { | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | return false | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // fileCrumbs is the path in the top bar: repo, kind ("tree" or "blob"), ref, | |||
| 96 | + | // then the segments of the path. The directories link to their tree. | |||
| 97 | + | func fileCrumbs(repo, kind string, t target) []Crumb { | |||
| 98 | + | root := repoHref(repo, "tree", t.name) | |||
| 99 | + | cs := []Crumb{ | |||
| 100 | + | {Label: repo, Href: repoHref(repo)}, | |||
| 101 | + | {Label: kind, Href: root}, | |||
| 102 | + | {Label: t.name, Href: root, Current: t.path == ""}, | |||
| 103 | + | } | |||
| 104 | + | if t.path == "" { | |||
| 105 | + | return cs | |||
| 106 | + | } | |||
| 107 | + | segs := strings.Split(t.path, "/") | |||
| 108 | + | for i, seg := range segs { | |||
| 109 | + | last := i == len(segs)-1 | |||
| 110 | + | c := Crumb{Label: markedText(seg), Current: last} | |||
| 111 | + | if !last || kind == "tree" { | |||
| 112 | + | c.Href = repoHref(repo, "tree", t.name, strings.Join(segs[:i+1], "/")) | |||
| 113 | + | } else { | |||
| 114 | + | c.Href = repoHref(repo, "blob", t.name, t.path) | |||
| 115 | + | } | |||
| 116 | + | cs = append(cs, c) | |||
| 117 | + | } | |||
| 118 | + | return cs | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | // maxFileTargets bounds the files of a directory offered in the ":" list. | |||
| 122 | + | const maxFileTargets = 200 | |||
| 123 | + | ||||
| 124 | + | // TreeRow is one entry of a directory. | |||
| 125 | + | type TreeRow struct { | |||
| 126 | + | Glyph string // ▾ directory, @ symbolic link, + submodule, · file | |||
| 127 | + | Name string | |||
| 128 | + | Href string // "" for a submodule | |||
| 129 | + | Size string | |||
| 130 | + | Subject string | |||
| 131 | + | CHref string | |||
| 132 | + | Author string | |||
| 133 | + | When string | |||
| 134 | + | WhenAt string | |||
| 135 | + | Cursor bool | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // TreePage is a directory. | |||
| 139 | + | type TreePage struct { | |||
| 140 | + | Rows []TreeRow | |||
| 141 | + | Readme *Readme | |||
| 142 | + | // Notes say what the listing leaves out. | |||
| 143 | + | Notes []string | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | // tree lists a directory of a ref, with the last commit of each entry, and | |||
| 147 | + | // renders the README of the directory below. | |||
| 148 | + | func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |||
| 149 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |||
| 150 | + | if err != nil { | |||
| 151 | + | return nil, 0, err | |||
| 152 | + | } | |||
| 153 | + | t, ok := d.locate(r.PathValue("rest")) | |||
| 154 | + | if !ok { | |||
| 155 | + | return nil, 0, errNotFound | |||
| 156 | + | } | |||
| 157 | + | res, err := s.cfg.Git.GetTree(ctx, connect.NewRequest(&pb.GetTreeRequest{ | |||
| 158 | + | Repo: d.name(), Revision: t.rev, Path: t.path, IncludeLastCommit: true, | |||
| 159 | + | })) | |||
| 160 | + | if err != nil { | |||
| 161 | + | return nil, 0, err | |||
| 162 | + | } | |||
| 163 | + | entries := res.Msg.GetEntries() | |||
| 164 | + | now := s.cfg.Now() | |||
| 165 | + | ||||
| 166 | + | p := TreePage{} | |||
| 167 | + | l := s.repoLayout(ctx, d, "tree") | |||
| 168 | + | for i, e := range entries { | |||
| 169 | + | row := TreeRow{Glyph: "·", Name: markedText(e.GetName()), Size: humanSize(e.GetSize()), Cursor: i == 0} | |||
| 170 | + | switch e.GetKind() { | |||
| 171 | + | case pb.EntryKind_ENTRY_KIND_DIRECTORY: | |||
| 172 | + | row.Glyph, row.Name, row.Size = "▾", row.Name+"/", "-" | |||
| 173 | + | row.Href = repoHref(d.name(), "tree", t.name, e.GetPath()) | |||
| 174 | + | case pb.EntryKind_ENTRY_KIND_SYMLINK: | |||
| 175 | + | row.Glyph = "@" | |||
| 176 | + | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |||
| 177 | + | case pb.EntryKind_ENTRY_KIND_SUBMODULE: | |||
| 178 | + | row.Glyph, row.Size = "+", "-" | |||
| 179 | + | default: | |||
| 180 | + | row.Href = repoHref(d.name(), "blob", t.name, e.GetPath()) | |||
| 181 | + | if len(l.Cmd) < maxFileTargets { | |||
| 182 | + | l.Cmd = append(l.Cmd, CmdItem{Kind: "file", Label: markedText(e.GetPath()), Href: row.Href}) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | if c := e.GetLastCommit(); c != nil { | |||
| 186 | + | row.Subject, row.Author = markedText(c.GetSummary()), c.GetAuthor().GetName() | |||
| 187 | + | row.CHref = repoHref(d.name(), "commit", c.GetId()) | |||
| 188 | + | if ts := c.GetCommitter().GetTime(); ts.IsValid() { | |||
| 189 | + | row.When, row.WhenAt = ago(now, ts.AsTime()), rfc3339(ts.AsTime()) | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | p.Rows = append(p.Rows, row) | |||
| 193 | + | } | |||
| 194 | + | if res.Msg.GetTruncated() { | |||
| 195 | + | p.Notes = append(p.Notes, "this directory has more entries than are listed here: the first "+itoa(len(entries))+" are shown.") | |||
| 196 | + | } | |||
| 197 | + | if res.Msg.GetLastCommitsTruncated() { | |||
| 198 | + | p.Notes = append(p.Notes, "the last commit is shown for the first entries only.") | |||
| 199 | + | } | |||
| 200 | + | rd, err := s.readmeIn(ctx, d.name(), res.Msg.GetCommitId(), t.name, t.path, entries) | |||
| 201 | + | if err != nil { | |||
| 202 | + | return nil, 0, err | |||
| 203 | + | } | |||
| 204 | + | if rd != nil { | |||
| 205 | + | p.Readme = rd.readme | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | pinRef(&l, t) | |||
| 209 | + | l.Crumbs = fileCrumbs(d.name(), "tree", t) | |||
| 210 | + | if t.path == "" { | |||
| 211 | + | l.Title = "tree · " + d.name() + " · " + s.instance | |||
| 212 | + | } else { | |||
| 213 | + | l.Title = markedText(t.path) + " · " + d.name() + " · " + s.instance | |||
| 214 | + | } | |||
| 215 | + | l.Status = Status{ | |||
| 216 | + | Mode: "tree", Context: "tree · " + plural(len(entries), "entry", "entries"), | |||
| 217 | + | Hints: "j/k move · enter open · / filter · b ref · ? help", Cursor: true, Count: len(p.Rows), | |||
| 218 | + | } | |||
| 219 | + | return Tree(l, p), http.StatusOK, nil | |||
| 220 | + | } | |||
A internal/adapters/web/tree.templ +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Tree is a directory: the filter, the entries with their last commit, the | |
| 6 | + | // notes on what is left out, and the README of the directory below. | |
| 7 | + | templ Tree(l Layout, p TreePage) { | |
| 8 | + | @Shell(l, nil) { | |
| 9 | + | <div class="filter"> | |
| 10 | + | <label for="tree-filter">filter></label> | |
| 11 | + | <input | |
| 12 | + | id="tree-filter" | |
| 13 | + | type="search" | |
| 14 | + | data-filter | |
| 15 | + | placeholder="this directory ( / )" | |
| 16 | + | autocomplete="off" | |
| 17 | + | hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')" | |
| 18 | + | /> | |
| 19 | + | </div> | |
| 20 | + | <table class="list tree" aria-label="Entries"> | |
| 21 | + | <thead> | |
| 22 | + | <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr> | |
| 23 | + | </thead> | |
| 24 | + | <tbody id="tree-rows" data-rows> | |
| 25 | + | for _, r := range p.Rows { | |
| 26 | + | @TreeRowView(r) | |
| 27 | + | } | |
| 28 | + | </tbody> | |
| 29 | + | </table> | |
| 30 | + | if len(p.Rows) == 0 { | |
| 31 | + | <p class="lede dim">this directory is empty.</p> | |
| 32 | + | } | |
| 33 | + | for _, n := range p.Notes { | |
| 34 | + | <p class="note">{ n }</p> | |
| 35 | + | } | |
| 36 | + | if p.Readme != nil { | |
| 37 | + | <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2> | |
| 38 | + | @ReadmeView(*p.Readme) | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic | |
| 44 | + | // link, + submodule, · file. | |
| 45 | + | templ TreeRowView(r TreeRow) { | |
| 46 | + | <tr data-cursor?={ r.Cursor }> | |
| 47 | + | <td class="cur" aria-hidden="true"></td> | |
| 48 | + | <td class="c-kind" aria-hidden="true">{ r.Glyph }</td> | |
| 49 | + | <td class="c-name"> | |
| 50 | + | if r.Href != "" { | |
| 51 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |
| 52 | + | } else { | |
| 53 | + | { r.Name } | |
| 54 | + | } | |
| 55 | + | </td> | |
| 56 | + | <td class="c-size num dim">{ r.Size }</td> | |
| 57 | + | <td class="c-sub dim"> | |
| 58 | + | if r.CHref != "" { | |
| 59 | + | <a href={ templ.SafeURL(r.CHref) }>{ r.Subject }</a> | |
| 60 | + | } | |
| 61 | + | </td> | |
| 62 | + | <td class="c-who dim">{ r.Author }</td> | |
| 63 | + | <td class="c-when nowrap dim"> | |
| 64 | + | if r.WhenAt != "" { | |
| 65 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time> | |
| 66 | + | } | |
| 67 | + | </td> | |
| 68 | + | </tr> | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | // Tree is a directory: the filter, the entries with their last commit, the | |||
| 6 | + | // notes on what is left out, and the README of the directory below. | |||
| 7 | + | templ Tree(l Layout, p TreePage) { | |||
| 8 | + | @Shell(l, nil) { | |||
| 9 | + | <div class="filter"> | |||
| 10 | + | <label for="tree-filter">filter></label> | |||
| 11 | + | <input | |||
| 12 | + | id="tree-filter" | |||
| 13 | + | type="search" | |||
| 14 | + | data-filter | |||
| 15 | + | placeholder="this directory ( / )" | |||
| 16 | + | autocomplete="off" | |||
| 17 | + | hx-on:input="let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')" | |||
| 18 | + | /> | |||
| 19 | + | </div> | |||
| 20 | + | <table class="list tree" aria-label="Entries"> | |||
| 21 | + | <thead> | |||
| 22 | + | <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr> | |||
| 23 | + | </thead> | |||
| 24 | + | <tbody id="tree-rows" data-rows> | |||
| 25 | + | for _, r := range p.Rows { | |||
| 26 | + | @TreeRowView(r) | |||
| 27 | + | } | |||
| 28 | + | </tbody> | |||
| 29 | + | </table> | |||
| 30 | + | if len(p.Rows) == 0 { | |||
| 31 | + | <p class="lede dim">this directory is empty.</p> | |||
| 32 | + | } | |||
| 33 | + | for _, n := range p.Notes { | |||
| 34 | + | <p class="note">{ n }</p> | |||
| 35 | + | } | |||
| 36 | + | if p.Readme != nil { | |||
| 37 | + | <h2 class="rule-h"><span>{ p.Readme.Name }</span></h2> | |||
| 38 | + | @ReadmeView(*p.Readme) | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic | |||
| 44 | + | // link, + submodule, · file. | |||
| 45 | + | templ TreeRowView(r TreeRow) { | |||
| 46 | + | <tr data-cursor?={ r.Cursor }> | |||
| 47 | + | <td class="cur" aria-hidden="true"></td> | |||
| 48 | + | <td class="c-kind" aria-hidden="true">{ r.Glyph }</td> | |||
| 49 | + | <td class="c-name"> | |||
| 50 | + | if r.Href != "" { | |||
| 51 | + | <a href={ templ.SafeURL(r.Href) }>{ r.Name }</a> | |||
| 52 | + | } else { | |||
| 53 | + | { r.Name } | |||
| 54 | + | } | |||
| 55 | + | </td> | |||
| 56 | + | <td class="c-size num dim">{ r.Size }</td> | |||
| 57 | + | <td class="c-sub dim"> | |||
| 58 | + | if r.CHref != "" { | |||
| 59 | + | <a href={ templ.SafeURL(r.CHref) }>{ r.Subject }</a> | |||
| 60 | + | } | |||
| 61 | + | </td> | |||
| 62 | + | <td class="c-who dim">{ r.Author }</td> | |||
| 63 | + | <td class="c-when nowrap dim"> | |||
| 64 | + | if r.WhenAt != "" { | |||
| 65 | + | <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time> | |||
| 66 | + | } | |||
| 67 | + | </td> | |||
| 68 | + | </tr> | |||
| 69 | + | } | |||
A internal/adapters/web/tree_templ.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |
| 2 | + | ||
| 3 | + | // templ: version: v0.3.1020 | |
| 4 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 5 | + | ||
| 6 | + | package web | |
| 7 | + | ||
| 8 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |
| 9 | + | ||
| 10 | + | import "github.com/a-h/templ" | |
| 11 | + | import templruntime "github.com/a-h/templ/runtime" | |
| 12 | + | ||
| 13 | + | // Tree is a directory: the filter, the entries with their last commit, the | |
| 14 | + | // notes on what is left out, and the README of the directory below. | |
| 15 | + | func Tree(l Layout, p TreePage) templ.Component { | |
| 16 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 17 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 18 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 19 | + | return templ_7745c5c3_CtxErr | |
| 20 | + | } | |
| 21 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 22 | + | if !templ_7745c5c3_IsBuffer { | |
| 23 | + | defer func() { | |
| 24 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 25 | + | if templ_7745c5c3_Err == nil { | |
| 26 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 27 | + | } | |
| 28 | + | }() | |
| 29 | + | } | |
| 30 | + | ctx = templ.InitializeContext(ctx) | |
| 31 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |
| 32 | + | if templ_7745c5c3_Var1 == nil { | |
| 33 | + | templ_7745c5c3_Var1 = templ.NopComponent | |
| 34 | + | } | |
| 35 | + | ctx = templ.ClearChildren(ctx) | |
| 36 | + | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 37 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 38 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 39 | + | if !templ_7745c5c3_IsBuffer { | |
| 40 | + | defer func() { | |
| 41 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 42 | + | if templ_7745c5c3_Err == nil { | |
| 43 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 44 | + | } | |
| 45 | + | }() | |
| 46 | + | } | |
| 47 | + | ctx = templ.InitializeContext(ctx) | |
| 48 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"filter\"><label for=\"tree-filter\">filter></label> <input id=\"tree-filter\" type=\"search\" data-filter placeholder=\"this directory ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')\"></div><table class=\"list tree\" aria-label=\"Entries\"><thead><tr><th class=\"cur\"></th><th class=\"c-kind\"></th><th class=\"c-name\">name</th><th class=\"c-size\">size</th><th class=\"c-sub\">last commit</th><th class=\"c-who\">author</th><th class=\"c-when\">date</th></tr></thead> <tbody id=\"tree-rows\" data-rows>") | |
| 49 | + | if templ_7745c5c3_Err != nil { | |
| 50 | + | return templ_7745c5c3_Err | |
| 51 | + | } | |
| 52 | + | for _, r := range p.Rows { | |
| 53 | + | templ_7745c5c3_Err = TreeRowView(r).Render(ctx, templ_7745c5c3_Buffer) | |
| 54 | + | if templ_7745c5c3_Err != nil { | |
| 55 | + | return templ_7745c5c3_Err | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</tbody></table>") | |
| 59 | + | if templ_7745c5c3_Err != nil { | |
| 60 | + | return templ_7745c5c3_Err | |
| 61 | + | } | |
| 62 | + | if len(p.Rows) == 0 { | |
| 63 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p class=\"lede dim\">this directory is empty.</p>") | |
| 64 | + | if templ_7745c5c3_Err != nil { | |
| 65 | + | return templ_7745c5c3_Err | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | for _, n := range p.Notes { | |
| 69 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"note\">") | |
| 70 | + | if templ_7745c5c3_Err != nil { | |
| 71 | + | return templ_7745c5c3_Err | |
| 72 | + | } | |
| 73 | + | var templ_7745c5c3_Var3 string | |
| 74 | + | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |
| 75 | + | if templ_7745c5c3_Err != nil { | |
| 76 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 34, Col: 22} | |
| 77 | + | } | |
| 78 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) | |
| 79 | + | if templ_7745c5c3_Err != nil { | |
| 80 | + | return templ_7745c5c3_Err | |
| 81 | + | } | |
| 82 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p>") | |
| 83 | + | if templ_7745c5c3_Err != nil { | |
| 84 | + | return templ_7745c5c3_Err | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") | |
| 88 | + | if templ_7745c5c3_Err != nil { | |
| 89 | + | return templ_7745c5c3_Err | |
| 90 | + | } | |
| 91 | + | if p.Readme != nil { | |
| 92 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h2 class=\"rule-h\"><span>") | |
| 93 | + | if templ_7745c5c3_Err != nil { | |
| 94 | + | return templ_7745c5c3_Err | |
| 95 | + | } | |
| 96 | + | var templ_7745c5c3_Var4 string | |
| 97 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.Readme.Name) | |
| 98 | + | if templ_7745c5c3_Err != nil { | |
| 99 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 37, Col: 43} | |
| 100 | + | } | |
| 101 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |
| 102 | + | if templ_7745c5c3_Err != nil { | |
| 103 | + | return templ_7745c5c3_Err | |
| 104 | + | } | |
| 105 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2>") | |
| 106 | + | if templ_7745c5c3_Err != nil { | |
| 107 | + | return templ_7745c5c3_Err | |
| 108 | + | } | |
| 109 | + | templ_7745c5c3_Err = ReadmeView(*p.Readme).Render(ctx, templ_7745c5c3_Buffer) | |
| 110 | + | if templ_7745c5c3_Err != nil { | |
| 111 | + | return templ_7745c5c3_Err | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return nil | |
| 115 | + | }) | |
| 116 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) | |
| 117 | + | if templ_7745c5c3_Err != nil { | |
| 118 | + | return templ_7745c5c3_Err | |
| 119 | + | } | |
| 120 | + | return nil | |
| 121 | + | }) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | // TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic | |
| 125 | + | // link, + submodule, · file. | |
| 126 | + | func TreeRowView(r TreeRow) templ.Component { | |
| 127 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 128 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 129 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 130 | + | return templ_7745c5c3_CtxErr | |
| 131 | + | } | |
| 132 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 133 | + | if !templ_7745c5c3_IsBuffer { | |
| 134 | + | defer func() { | |
| 135 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 136 | + | if templ_7745c5c3_Err == nil { | |
| 137 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 138 | + | } | |
| 139 | + | }() | |
| 140 | + | } | |
| 141 | + | ctx = templ.InitializeContext(ctx) | |
| 142 | + | templ_7745c5c3_Var5 := templ.GetChildren(ctx) | |
| 143 | + | if templ_7745c5c3_Var5 == nil { | |
| 144 | + | templ_7745c5c3_Var5 = templ.NopComponent | |
| 145 | + | } | |
| 146 | + | ctx = templ.ClearChildren(ctx) | |
| 147 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tr") | |
| 148 | + | if templ_7745c5c3_Err != nil { | |
| 149 | + | return templ_7745c5c3_Err | |
| 150 | + | } | |
| 151 | + | if r.Cursor { | |
| 152 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " data-cursor") | |
| 153 | + | if templ_7745c5c3_Err != nil { | |
| 154 | + | return templ_7745c5c3_Err | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind\" aria-hidden=\"true\">") | |
| 158 | + | if templ_7745c5c3_Err != nil { | |
| 159 | + | return templ_7745c5c3_Err | |
| 160 | + | } | |
| 161 | + | var templ_7745c5c3_Var6 string | |
| 162 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Glyph) | |
| 163 | + | if templ_7745c5c3_Err != nil { | |
| 164 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 48, Col: 49} | |
| 165 | + | } | |
| 166 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |
| 167 | + | if templ_7745c5c3_Err != nil { | |
| 168 | + | return templ_7745c5c3_Err | |
| 169 | + | } | |
| 170 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td><td class=\"c-name\">") | |
| 171 | + | if templ_7745c5c3_Err != nil { | |
| 172 | + | return templ_7745c5c3_Err | |
| 173 | + | } | |
| 174 | + | if r.Href != "" { | |
| 175 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"") | |
| 176 | + | if templ_7745c5c3_Err != nil { | |
| 177 | + | return templ_7745c5c3_Err | |
| 178 | + | } | |
| 179 | + | var templ_7745c5c3_Var7 templ.SafeURL | |
| 180 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |
| 181 | + | if templ_7745c5c3_Err != nil { | |
| 182 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 35} | |
| 183 | + | } | |
| 184 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |
| 185 | + | if templ_7745c5c3_Err != nil { | |
| 186 | + | return templ_7745c5c3_Err | |
| 187 | + | } | |
| 188 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">") | |
| 189 | + | if templ_7745c5c3_Err != nil { | |
| 190 | + | return templ_7745c5c3_Err | |
| 191 | + | } | |
| 192 | + | var templ_7745c5c3_Var8 string | |
| 193 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 194 | + | if templ_7745c5c3_Err != nil { | |
| 195 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 46} | |
| 196 | + | } | |
| 197 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |
| 198 | + | if templ_7745c5c3_Err != nil { | |
| 199 | + | return templ_7745c5c3_Err | |
| 200 | + | } | |
| 201 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>") | |
| 202 | + | if templ_7745c5c3_Err != nil { | |
| 203 | + | return templ_7745c5c3_Err | |
| 204 | + | } | |
| 205 | + | } else { | |
| 206 | + | var templ_7745c5c3_Var9 string | |
| 207 | + | templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |
| 208 | + | if templ_7745c5c3_Err != nil { | |
| 209 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 53, Col: 12} | |
| 210 | + | } | |
| 211 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) | |
| 212 | + | if templ_7745c5c3_Err != nil { | |
| 213 | + | return templ_7745c5c3_Err | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td class=\"c-size num dim\">") | |
| 217 | + | if templ_7745c5c3_Err != nil { | |
| 218 | + | return templ_7745c5c3_Err | |
| 219 | + | } | |
| 220 | + | var templ_7745c5c3_Var10 string | |
| 221 | + | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Size) | |
| 222 | + | if templ_7745c5c3_Err != nil { | |
| 223 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 56, Col: 37} | |
| 224 | + | } | |
| 225 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |
| 226 | + | if templ_7745c5c3_Err != nil { | |
| 227 | + | return templ_7745c5c3_Err | |
| 228 | + | } | |
| 229 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td class=\"c-sub dim\">") | |
| 230 | + | if templ_7745c5c3_Err != nil { | |
| 231 | + | return templ_7745c5c3_Err | |
| 232 | + | } | |
| 233 | + | if r.CHref != "" { | |
| 234 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"") | |
| 235 | + | if templ_7745c5c3_Err != nil { | |
| 236 | + | return templ_7745c5c3_Err | |
| 237 | + | } | |
| 238 | + | var templ_7745c5c3_Var11 templ.SafeURL | |
| 239 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |
| 240 | + | if templ_7745c5c3_Err != nil { | |
| 241 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 36} | |
| 242 | + | } | |
| 243 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |
| 244 | + | if templ_7745c5c3_Err != nil { | |
| 245 | + | return templ_7745c5c3_Err | |
| 246 | + | } | |
| 247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">") | |
| 248 | + | if templ_7745c5c3_Err != nil { | |
| 249 | + | return templ_7745c5c3_Err | |
| 250 | + | } | |
| 251 | + | var templ_7745c5c3_Var12 string | |
| 252 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject) | |
| 253 | + | if templ_7745c5c3_Err != nil { | |
| 254 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 50} | |
| 255 | + | } | |
| 256 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |
| 257 | + | if templ_7745c5c3_Err != nil { | |
| 258 | + | return templ_7745c5c3_Err | |
| 259 | + | } | |
| 260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a>") | |
| 261 | + | if templ_7745c5c3_Err != nil { | |
| 262 | + | return templ_7745c5c3_Err | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"c-who dim\">") | |
| 266 | + | if templ_7745c5c3_Err != nil { | |
| 267 | + | return templ_7745c5c3_Err | |
| 268 | + | } | |
| 269 | + | var templ_7745c5c3_Var13 string | |
| 270 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Author) | |
| 271 | + | if templ_7745c5c3_Err != nil { | |
| 272 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 62, Col: 34} | |
| 273 | + | } | |
| 274 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |
| 275 | + | if templ_7745c5c3_Err != nil { | |
| 276 | + | return templ_7745c5c3_Err | |
| 277 | + | } | |
| 278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-when nowrap dim\">") | |
| 279 | + | if templ_7745c5c3_Err != nil { | |
| 280 | + | return templ_7745c5c3_Err | |
| 281 | + | } | |
| 282 | + | if r.WhenAt != "" { | |
| 283 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<time datetime=\"") | |
| 284 | + | if templ_7745c5c3_Err != nil { | |
| 285 | + | return templ_7745c5c3_Err | |
| 286 | + | } | |
| 287 | + | var templ_7745c5c3_Var14 string | |
| 288 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 289 | + | if templ_7745c5c3_Err != nil { | |
| 290 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 29} | |
| 291 | + | } | |
| 292 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |
| 293 | + | if templ_7745c5c3_Err != nil { | |
| 294 | + | return templ_7745c5c3_Err | |
| 295 | + | } | |
| 296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" title=\"") | |
| 297 | + | if templ_7745c5c3_Err != nil { | |
| 298 | + | return templ_7745c5c3_Err | |
| 299 | + | } | |
| 300 | + | var templ_7745c5c3_Var15 string | |
| 301 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |
| 302 | + | if templ_7745c5c3_Err != nil { | |
| 303 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 48} | |
| 304 | + | } | |
| 305 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |
| 306 | + | if templ_7745c5c3_Err != nil { | |
| 307 | + | return templ_7745c5c3_Err | |
| 308 | + | } | |
| 309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">") | |
| 310 | + | if templ_7745c5c3_Err != nil { | |
| 311 | + | return templ_7745c5c3_Err | |
| 312 | + | } | |
| 313 | + | var templ_7745c5c3_Var16 string | |
| 314 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |
| 315 | + | if templ_7745c5c3_Err != nil { | |
| 316 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 59} | |
| 317 | + | } | |
| 318 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |
| 319 | + | if templ_7745c5c3_Err != nil { | |
| 320 | + | return templ_7745c5c3_Err | |
| 321 | + | } | |
| 322 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</time>") | |
| 323 | + | if templ_7745c5c3_Err != nil { | |
| 324 | + | return templ_7745c5c3_Err | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td></tr>") | |
| 328 | + | if templ_7745c5c3_Err != nil { | |
| 329 | + | return templ_7745c5c3_Err | |
| 330 | + | } | |
| 331 | + | return nil | |
| 332 | + | }) | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | var _ = templruntime.GeneratedTemplate | |
| @@ -0,0 +1,335 @@ | |||||
| 1 | + | // Code generated by templ - DO NOT EDIT. | |||
| 2 | + | ||||
| 3 | + | // templ: version: v0.3.1020 | |||
| 4 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 5 | + | ||||
| 6 | + | package web | |||
| 7 | + | ||||
| 8 | + | //lint:file-ignore SA4006 This context is only used if a nested component is present. | |||
| 9 | + | ||||
| 10 | + | import "github.com/a-h/templ" | |||
| 11 | + | import templruntime "github.com/a-h/templ/runtime" | |||
| 12 | + | ||||
| 13 | + | // Tree is a directory: the filter, the entries with their last commit, the | |||
| 14 | + | // notes on what is left out, and the README of the directory below. | |||
| 15 | + | func Tree(l Layout, p TreePage) templ.Component { | |||
| 16 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 17 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 18 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 19 | + | return templ_7745c5c3_CtxErr | |||
| 20 | + | } | |||
| 21 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 22 | + | if !templ_7745c5c3_IsBuffer { | |||
| 23 | + | defer func() { | |||
| 24 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 25 | + | if templ_7745c5c3_Err == nil { | |||
| 26 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 27 | + | } | |||
| 28 | + | }() | |||
| 29 | + | } | |||
| 30 | + | ctx = templ.InitializeContext(ctx) | |||
| 31 | + | templ_7745c5c3_Var1 := templ.GetChildren(ctx) | |||
| 32 | + | if templ_7745c5c3_Var1 == nil { | |||
| 33 | + | templ_7745c5c3_Var1 = templ.NopComponent | |||
| 34 | + | } | |||
| 35 | + | ctx = templ.ClearChildren(ctx) | |||
| 36 | + | templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 37 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 38 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 39 | + | if !templ_7745c5c3_IsBuffer { | |||
| 40 | + | defer func() { | |||
| 41 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 42 | + | if templ_7745c5c3_Err == nil { | |||
| 43 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 44 | + | } | |||
| 45 | + | }() | |||
| 46 | + | } | |||
| 47 | + | ctx = templ.InitializeContext(ctx) | |||
| 48 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"filter\"><label for=\"tree-filter\">filter></label> <input id=\"tree-filter\" type=\"search\" data-filter placeholder=\"this directory ( / )\" autocomplete=\"off\" hx-on:input=\"let t = this.value.toLowerCase(); q('#tree-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#tree-rows > tr:not([hidden])')\"></div><table class=\"list tree\" aria-label=\"Entries\"><thead><tr><th class=\"cur\"></th><th class=\"c-kind\"></th><th class=\"c-name\">name</th><th class=\"c-size\">size</th><th class=\"c-sub\">last commit</th><th class=\"c-who\">author</th><th class=\"c-when\">date</th></tr></thead> <tbody id=\"tree-rows\" data-rows>") | |||
| 49 | + | if templ_7745c5c3_Err != nil { | |||
| 50 | + | return templ_7745c5c3_Err | |||
| 51 | + | } | |||
| 52 | + | for _, r := range p.Rows { | |||
| 53 | + | templ_7745c5c3_Err = TreeRowView(r).Render(ctx, templ_7745c5c3_Buffer) | |||
| 54 | + | if templ_7745c5c3_Err != nil { | |||
| 55 | + | return templ_7745c5c3_Err | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</tbody></table>") | |||
| 59 | + | if templ_7745c5c3_Err != nil { | |||
| 60 | + | return templ_7745c5c3_Err | |||
| 61 | + | } | |||
| 62 | + | if len(p.Rows) == 0 { | |||
| 63 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<p class=\"lede dim\">this directory is empty.</p>") | |||
| 64 | + | if templ_7745c5c3_Err != nil { | |||
| 65 | + | return templ_7745c5c3_Err | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | for _, n := range p.Notes { | |||
| 69 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<p class=\"note\">") | |||
| 70 | + | if templ_7745c5c3_Err != nil { | |||
| 71 | + | return templ_7745c5c3_Err | |||
| 72 | + | } | |||
| 73 | + | var templ_7745c5c3_Var3 string | |||
| 74 | + | templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(n) | |||
| 75 | + | if templ_7745c5c3_Err != nil { | |||
| 76 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 34, Col: 22} | |||
| 77 | + | } | |||
| 78 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) | |||
| 79 | + | if templ_7745c5c3_Err != nil { | |||
| 80 | + | return templ_7745c5c3_Err | |||
| 81 | + | } | |||
| 82 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</p>") | |||
| 83 | + | if templ_7745c5c3_Err != nil { | |||
| 84 | + | return templ_7745c5c3_Err | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " ") | |||
| 88 | + | if templ_7745c5c3_Err != nil { | |||
| 89 | + | return templ_7745c5c3_Err | |||
| 90 | + | } | |||
| 91 | + | if p.Readme != nil { | |||
| 92 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<h2 class=\"rule-h\"><span>") | |||
| 93 | + | if templ_7745c5c3_Err != nil { | |||
| 94 | + | return templ_7745c5c3_Err | |||
| 95 | + | } | |||
| 96 | + | var templ_7745c5c3_Var4 string | |||
| 97 | + | templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(p.Readme.Name) | |||
| 98 | + | if templ_7745c5c3_Err != nil { | |||
| 99 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 37, Col: 43} | |||
| 100 | + | } | |||
| 101 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | |||
| 102 | + | if templ_7745c5c3_Err != nil { | |||
| 103 | + | return templ_7745c5c3_Err | |||
| 104 | + | } | |||
| 105 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></h2>") | |||
| 106 | + | if templ_7745c5c3_Err != nil { | |||
| 107 | + | return templ_7745c5c3_Err | |||
| 108 | + | } | |||
| 109 | + | templ_7745c5c3_Err = ReadmeView(*p.Readme).Render(ctx, templ_7745c5c3_Buffer) | |||
| 110 | + | if templ_7745c5c3_Err != nil { | |||
| 111 | + | return templ_7745c5c3_Err | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return nil | |||
| 115 | + | }) | |||
| 116 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) | |||
| 117 | + | if templ_7745c5c3_Err != nil { | |||
| 118 | + | return templ_7745c5c3_Err | |||
| 119 | + | } | |||
| 120 | + | return nil | |||
| 121 | + | }) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // TreeRowView is one entry of a directory. Glyph: ▾ directory, @ symbolic | |||
| 125 | + | // link, + submodule, · file. | |||
| 126 | + | func TreeRowView(r TreeRow) templ.Component { | |||
| 127 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 128 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 129 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 130 | + | return templ_7745c5c3_CtxErr | |||
| 131 | + | } | |||
| 132 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 133 | + | if !templ_7745c5c3_IsBuffer { | |||
| 134 | + | defer func() { | |||
| 135 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 136 | + | if templ_7745c5c3_Err == nil { | |||
| 137 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 138 | + | } | |||
| 139 | + | }() | |||
| 140 | + | } | |||
| 141 | + | ctx = templ.InitializeContext(ctx) | |||
| 142 | + | templ_7745c5c3_Var5 := templ.GetChildren(ctx) | |||
| 143 | + | if templ_7745c5c3_Var5 == nil { | |||
| 144 | + | templ_7745c5c3_Var5 = templ.NopComponent | |||
| 145 | + | } | |||
| 146 | + | ctx = templ.ClearChildren(ctx) | |||
| 147 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<tr") | |||
| 148 | + | if templ_7745c5c3_Err != nil { | |||
| 149 | + | return templ_7745c5c3_Err | |||
| 150 | + | } | |||
| 151 | + | if r.Cursor { | |||
| 152 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, " data-cursor") | |||
| 153 | + | if templ_7745c5c3_Err != nil { | |||
| 154 | + | return templ_7745c5c3_Err | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "><td class=\"cur\" aria-hidden=\"true\"></td><td class=\"c-kind\" aria-hidden=\"true\">") | |||
| 158 | + | if templ_7745c5c3_Err != nil { | |||
| 159 | + | return templ_7745c5c3_Err | |||
| 160 | + | } | |||
| 161 | + | var templ_7745c5c3_Var6 string | |||
| 162 | + | templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(r.Glyph) | |||
| 163 | + | if templ_7745c5c3_Err != nil { | |||
| 164 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 48, Col: 49} | |||
| 165 | + | } | |||
| 166 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) | |||
| 167 | + | if templ_7745c5c3_Err != nil { | |||
| 168 | + | return templ_7745c5c3_Err | |||
| 169 | + | } | |||
| 170 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</td><td class=\"c-name\">") | |||
| 171 | + | if templ_7745c5c3_Err != nil { | |||
| 172 | + | return templ_7745c5c3_Err | |||
| 173 | + | } | |||
| 174 | + | if r.Href != "" { | |||
| 175 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"") | |||
| 176 | + | if templ_7745c5c3_Err != nil { | |||
| 177 | + | return templ_7745c5c3_Err | |||
| 178 | + | } | |||
| 179 | + | var templ_7745c5c3_Var7 templ.SafeURL | |||
| 180 | + | templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.Href)) | |||
| 181 | + | if templ_7745c5c3_Err != nil { | |||
| 182 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 35} | |||
| 183 | + | } | |||
| 184 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | |||
| 185 | + | if templ_7745c5c3_Err != nil { | |||
| 186 | + | return templ_7745c5c3_Err | |||
| 187 | + | } | |||
| 188 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">") | |||
| 189 | + | if templ_7745c5c3_Err != nil { | |||
| 190 | + | return templ_7745c5c3_Err | |||
| 191 | + | } | |||
| 192 | + | var templ_7745c5c3_Var8 string | |||
| 193 | + | templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |||
| 194 | + | if templ_7745c5c3_Err != nil { | |||
| 195 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 51, Col: 46} | |||
| 196 | + | } | |||
| 197 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | |||
| 198 | + | if templ_7745c5c3_Err != nil { | |||
| 199 | + | return templ_7745c5c3_Err | |||
| 200 | + | } | |||
| 201 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</a>") | |||
| 202 | + | if templ_7745c5c3_Err != nil { | |||
| 203 | + | return templ_7745c5c3_Err | |||
| 204 | + | } | |||
| 205 | + | } else { | |||
| 206 | + | var templ_7745c5c3_Var9 string | |||
| 207 | + | templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name) | |||
| 208 | + | if templ_7745c5c3_Err != nil { | |||
| 209 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 53, Col: 12} | |||
| 210 | + | } | |||
| 211 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) | |||
| 212 | + | if templ_7745c5c3_Err != nil { | |||
| 213 | + | return templ_7745c5c3_Err | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</td><td class=\"c-size num dim\">") | |||
| 217 | + | if templ_7745c5c3_Err != nil { | |||
| 218 | + | return templ_7745c5c3_Err | |||
| 219 | + | } | |||
| 220 | + | var templ_7745c5c3_Var10 string | |||
| 221 | + | templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Size) | |||
| 222 | + | if templ_7745c5c3_Err != nil { | |||
| 223 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 56, Col: 37} | |||
| 224 | + | } | |||
| 225 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | |||
| 226 | + | if templ_7745c5c3_Err != nil { | |||
| 227 | + | return templ_7745c5c3_Err | |||
| 228 | + | } | |||
| 229 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</td><td class=\"c-sub dim\">") | |||
| 230 | + | if templ_7745c5c3_Err != nil { | |||
| 231 | + | return templ_7745c5c3_Err | |||
| 232 | + | } | |||
| 233 | + | if r.CHref != "" { | |||
| 234 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<a href=\"") | |||
| 235 | + | if templ_7745c5c3_Err != nil { | |||
| 236 | + | return templ_7745c5c3_Err | |||
| 237 | + | } | |||
| 238 | + | var templ_7745c5c3_Var11 templ.SafeURL | |||
| 239 | + | templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(r.CHref)) | |||
| 240 | + | if templ_7745c5c3_Err != nil { | |||
| 241 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 36} | |||
| 242 | + | } | |||
| 243 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | |||
| 244 | + | if templ_7745c5c3_Err != nil { | |||
| 245 | + | return templ_7745c5c3_Err | |||
| 246 | + | } | |||
| 247 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\">") | |||
| 248 | + | if templ_7745c5c3_Err != nil { | |||
| 249 | + | return templ_7745c5c3_Err | |||
| 250 | + | } | |||
| 251 | + | var templ_7745c5c3_Var12 string | |||
| 252 | + | templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(r.Subject) | |||
| 253 | + | if templ_7745c5c3_Err != nil { | |||
| 254 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 59, Col: 50} | |||
| 255 | + | } | |||
| 256 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | |||
| 257 | + | if templ_7745c5c3_Err != nil { | |||
| 258 | + | return templ_7745c5c3_Err | |||
| 259 | + | } | |||
| 260 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</a>") | |||
| 261 | + | if templ_7745c5c3_Err != nil { | |||
| 262 | + | return templ_7745c5c3_Err | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</td><td class=\"c-who dim\">") | |||
| 266 | + | if templ_7745c5c3_Err != nil { | |||
| 267 | + | return templ_7745c5c3_Err | |||
| 268 | + | } | |||
| 269 | + | var templ_7745c5c3_Var13 string | |||
| 270 | + | templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(r.Author) | |||
| 271 | + | if templ_7745c5c3_Err != nil { | |||
| 272 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 62, Col: 34} | |||
| 273 | + | } | |||
| 274 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | |||
| 275 | + | if templ_7745c5c3_Err != nil { | |||
| 276 | + | return templ_7745c5c3_Err | |||
| 277 | + | } | |||
| 278 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</td><td class=\"c-when nowrap dim\">") | |||
| 279 | + | if templ_7745c5c3_Err != nil { | |||
| 280 | + | return templ_7745c5c3_Err | |||
| 281 | + | } | |||
| 282 | + | if r.WhenAt != "" { | |||
| 283 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<time datetime=\"") | |||
| 284 | + | if templ_7745c5c3_Err != nil { | |||
| 285 | + | return templ_7745c5c3_Err | |||
| 286 | + | } | |||
| 287 | + | var templ_7745c5c3_Var14 string | |||
| 288 | + | templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |||
| 289 | + | if templ_7745c5c3_Err != nil { | |||
| 290 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 29} | |||
| 291 | + | } | |||
| 292 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) | |||
| 293 | + | if templ_7745c5c3_Err != nil { | |||
| 294 | + | return templ_7745c5c3_Err | |||
| 295 | + | } | |||
| 296 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" title=\"") | |||
| 297 | + | if templ_7745c5c3_Err != nil { | |||
| 298 | + | return templ_7745c5c3_Err | |||
| 299 | + | } | |||
| 300 | + | var templ_7745c5c3_Var15 string | |||
| 301 | + | templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.WhenAt) | |||
| 302 | + | if templ_7745c5c3_Err != nil { | |||
| 303 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 48} | |||
| 304 | + | } | |||
| 305 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15) | |||
| 306 | + | if templ_7745c5c3_Err != nil { | |||
| 307 | + | return templ_7745c5c3_Err | |||
| 308 | + | } | |||
| 309 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\">") | |||
| 310 | + | if templ_7745c5c3_Err != nil { | |||
| 311 | + | return templ_7745c5c3_Err | |||
| 312 | + | } | |||
| 313 | + | var templ_7745c5c3_Var16 string | |||
| 314 | + | templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(r.When) | |||
| 315 | + | if templ_7745c5c3_Err != nil { | |||
| 316 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/tree.templ`, Line: 65, Col: 59} | |||
| 317 | + | } | |||
| 318 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) | |||
| 319 | + | if templ_7745c5c3_Err != nil { | |||
| 320 | + | return templ_7745c5c3_Err | |||
| 321 | + | } | |||
| 322 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "</time>") | |||
| 323 | + | if templ_7745c5c3_Err != nil { | |||
| 324 | + | return templ_7745c5c3_Err | |||
| 325 | + | } | |||
| 326 | + | } | |||
| 327 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</td></tr>") | |||
| 328 | + | if templ_7745c5c3_Err != nil { | |||
| 329 | + | return templ_7745c5c3_Err | |||
| 330 | + | } | |||
| 331 | + | return nil | |||
| 332 | + | }) | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | var _ = templruntime.GeneratedTemplate | |||
M internal/adapters/web/web.go +2 -0
| @@ -101,6 +101,8 @@ func (s *Server) routes() *http.ServeMux { | |||
| 101 | 101 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | |
| 102 | 102 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | |
| 103 | 103 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | |
| 104 | + | mux.HandleFunc("GET /{repo}/tree", s.page(s.tree)) | |
| 105 | + | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | |
| 104 | 106 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 105 | 107 | return mux | |
| 106 | 108 | } | |
| @@ -101,6 +101,8 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 101 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | 101 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | ||
| 102 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | 102 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | ||
| 103 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | 103 | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | ||
| 104 | + | mux.HandleFunc("GET /{repo}/tree", s.page(s.tree)) | |||
| 105 | + | mux.HandleFunc("GET /{repo}/tree/{rest...}", s.page(s.tree)) | |||
| 104 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | 106 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | ||
| 105 | return mux | 107 | return mux | ||
| 106 | } | 108 | } | ||