feat(web): add commit page with unified and split diffs
/<repo>/commit/<sha> (full or abbreviated id): message, author and committer, parent links, refs, totals, the file list in the rail and the diff of each file in a <details>. Unified is the default; ?view=split selects the split layout on the server, and the toggle links work without JavaScript (with it, both layouts are in the page and d/u/x flip them). Truncated and binary files are collapsed under a note, renames, copies and mode changes are noted, an incomplete diff says so. Invisible characters (bidi controls, zero-width, controls) show as <U+XXXX> markers; every changed line keeps its +/- marker.
- commit
- 4f18354aa685a080ab722965d5166ff76f311fee
- author
- brice · · 2026-10-10 13:03 UTC
- parent
- dad3569
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
16 files · +6129 -0
A cmd/ermite/testdata/web/commit-big.html +352 -0
| @@ -0,0 +1,352 @@ | |||
| 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>SHA7 add a very large file · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>add a very large file</h1> | |
| 60 | + | <dl class="kv show-kv"> | |
| 61 | + | <dt>commit</dt> | |
| 62 | + | <dd>SHA40</dd> | |
| 63 | + | <dt>author</dt> | |
| 64 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 65 | + | <dt>parent</dt> | |
| 66 | + | <dd> | |
| 67 | + | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 68 | + | </dd> | |
| 69 | + | <dt>refs</dt> | |
| 70 | + | <dd>[main] </dd> | |
| 71 | + | </dl> | |
| 72 | + | </header> | |
| 73 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 74 | + | <span class="dim">1 file · <span class="plus">+2600</span> | |
| 75 | + | <span class="minus">-0</span> | |
| 76 | + | </span> | |
| 77 | + | <span class="grow"> | |
| 78 | + | </span> | |
| 79 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 80 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 81 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 82 | + | </div> | |
| 83 | + | <p class="notice">this diff is incomplete: 1 file is too large to show. the totals above count everything; clone the repository to read the rest.</p> | |
| 84 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 85 | + | <details class="dfile" id="f1" data-file> | |
| 86 | + | <summary> | |
| 87 | + | <span class="st">A</span> | |
| 88 | + | <span class="p">big.txt</span> | |
| 89 | + | <span class="plus">+2600</span> | |
| 90 | + | <span class="minus">-0</span> | |
| 91 | + | <span class="note">diff too large to show (2600 lines changed): collapsed</span> | |
| 92 | + | </summary> | |
| 93 | + | <p class="note pad">diff too large to show (2600 lines changed): collapsed</p> | |
| 94 | + | </details> | |
| 95 | + | </div> | |
| 96 | + | </main> | |
| 97 | + | <aside class="rail" aria-label="Context"> | |
| 98 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 99 | + | <summary>1 changed file</summary> | |
| 100 | + | <section aria-labelledby="r-files"> | |
| 101 | + | <h2 id="r-files">files <span>1</span> | |
| 102 | + | </h2> | |
| 103 | + | <ol class="files" aria-label="Changed files"> | |
| 104 | + | <li> | |
| 105 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 106 | + | <span class="st" title="A">A</span> | |
| 107 | + | <span class="p" title="big.txt">big.txt</span> | |
| 108 | + | <span class="n"> | |
| 109 | + | <span class="plus">+2600</span> | |
| 110 | + | <span class="minus">-0</span> | |
| 111 | + | </span> | |
| 112 | + | <span class="b" aria-hidden="true"> | |
| 113 | + | <span class="plus">++++++++++</span> | |
| 114 | + | <span class="minus"> | |
| 115 | + | </span> | |
| 116 | + | </span> | |
| 117 | + | </a> | |
| 118 | + | </li> | |
| 119 | + | </ol> | |
| 120 | + | </section> | |
| 121 | + | </details> | |
| 122 | + | </aside> | |
| 123 | + | </div> | |
| 124 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 125 | + | <div class="panel-head"> | |
| 126 | + | <span>keys</span> | |
| 127 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 128 | + | </div> | |
| 129 | + | <dl class="keys"> | |
| 130 | + | <div> | |
| 131 | + | <dt> | |
| 132 | + | <kbd>j</kbd> | |
| 133 | + | <kbd>k</kbd> | |
| 134 | + | </dt> | |
| 135 | + | <dd>move cursor down / up</dd> | |
| 136 | + | </div> | |
| 137 | + | <div> | |
| 138 | + | <dt> | |
| 139 | + | <kbd>enter</kbd> | |
| 140 | + | </dt> | |
| 141 | + | <dd>open row under cursor</dd> | |
| 142 | + | </div> | |
| 143 | + | <div> | |
| 144 | + | <dt> | |
| 145 | + | <kbd>g</kbd> | |
| 146 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 147 | + | </dt> | |
| 148 | + | <dd>first / last row</dd> | |
| 149 | + | </div> | |
| 150 | + | <div> | |
| 151 | + | <dt> | |
| 152 | + | <kbd>/</kbd> | |
| 153 | + | </dt> | |
| 154 | + | <dd>focus the filter</dd> | |
| 155 | + | </div> | |
| 156 | + | <div> | |
| 157 | + | <dt> | |
| 158 | + | <kbd>g h</kbd> | |
| 159 | + | <kbd>g t</kbd> | |
| 160 | + | </dt> | |
| 161 | + | <dd>go home / tree</dd> | |
| 162 | + | </div> | |
| 163 | + | <div> | |
| 164 | + | <dt> | |
| 165 | + | <kbd>g c</kbd> | |
| 166 | + | <kbd>g r</kbd> | |
| 167 | + | </dt> | |
| 168 | + | <dd>go commits / refs</dd> | |
| 169 | + | </div> | |
| 170 | + | <div> | |
| 171 | + | <dt> | |
| 172 | + | <kbd>b</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>switch ref (branches, tags)</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>:</kbd> | |
| 179 | + | <kbd>f</kbd> | |
| 180 | + | </dt> | |
| 181 | + | <dd>go to: section, ref, file, repo</dd> | |
| 182 | + | </div> | |
| 183 | + | <div> | |
| 184 | + | <dt> | |
| 185 | + | <kbd>y</kbd> | |
| 186 | + | </dt> | |
| 187 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 188 | + | </div> | |
| 189 | + | <div> | |
| 190 | + | <dt> | |
| 191 | + | <kbd>s</kbd> | |
| 192 | + | <kbd>t</kbd> | |
| 193 | + | <kbd>c</kbd> | |
| 194 | + | <kbd>r</kbd> | |
| 195 | + | </dt> | |
| 196 | + | <dd>repo sections (when shown)</dd> | |
| 197 | + | </div> | |
| 198 | + | <div> | |
| 199 | + | <dt> | |
| 200 | + | <kbd>d</kbd> | |
| 201 | + | <kbd>u</kbd> | |
| 202 | + | <kbd>x</kbd> | |
| 203 | + | </dt> | |
| 204 | + | <dd>commit: split / unified / collapse all</dd> | |
| 205 | + | </div> | |
| 206 | + | <div> | |
| 207 | + | <dt> | |
| 208 | + | <kbd>esc</kbd> | |
| 209 | + | </dt> | |
| 210 | + | <dd>close panel</dd> | |
| 211 | + | </div> | |
| 212 | + | </dl> | |
| 213 | + | </dialog> | |
| 214 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 215 | + | <div class="panel-head"> | |
| 216 | + | <label for="refs-filter">ref></label> | |
| 217 | + | <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))"> | |
| 218 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 219 | + | </div> | |
| 220 | + | <div class="panel-cols" data-picklist> | |
| 221 | + | <section aria-label="Branches"> | |
| 222 | + | <h2>branches <span class="mute">2</span> | |
| 223 | + | </h2> | |
| 224 | + | <ul> | |
| 225 | + | <li> | |
| 226 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 227 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 228 | + | </li> | |
| 229 | + | <li> | |
| 230 | + | <a href="/diffs/tree/side"> | |
| 231 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 232 | + | </li> | |
| 233 | + | </ul> | |
| 234 | + | </section> | |
| 235 | + | <section aria-label="Tags"> | |
| 236 | + | <h2>tags <span class="mute">1</span> | |
| 237 | + | </h2> | |
| 238 | + | <ul> | |
| 239 | + | <li> | |
| 240 | + | <a href="/diffs/tree/v1.0.0"> | |
| 241 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 242 | + | </li> | |
| 243 | + | </ul> | |
| 244 | + | </section> | |
| 245 | + | </div> | |
| 246 | + | </dialog> | |
| 247 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 248 | + | <div class="panel-head"> | |
| 249 | + | <label for="cmd-q" class="hi">:</label> | |
| 250 | + | <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(); | |
| 251 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 252 | + | 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(); | |
| 253 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 254 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 255 | + | 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' })) } | |
| 256 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 257 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 258 | + | </div> | |
| 259 | + | <ul id="cmd-list" class="cmd-list"> | |
| 260 | + | <li data-k="go home" data-cursor> | |
| 261 | + | <a href="/" tabindex="-1"> | |
| 262 | + | <span class="k">go</span>home</a> | |
| 263 | + | </li> | |
| 264 | + | <li data-k="section summary"> | |
| 265 | + | <a href="/diffs" tabindex="-1"> | |
| 266 | + | <span class="k">section</span>summary</a> | |
| 267 | + | </li> | |
| 268 | + | <li data-k="section tree"> | |
| 269 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 270 | + | <span class="k">section</span>tree</a> | |
| 271 | + | </li> | |
| 272 | + | <li data-k="section commits"> | |
| 273 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 274 | + | <span class="k">section</span>commits</a> | |
| 275 | + | </li> | |
| 276 | + | <li data-k="section refs"> | |
| 277 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 278 | + | <span class="k">section</span>refs</a> | |
| 279 | + | </li> | |
| 280 | + | <li data-k="branch main"> | |
| 281 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 282 | + | <span class="k">branch</span>main</a> | |
| 283 | + | </li> | |
| 284 | + | <li data-k="branch side"> | |
| 285 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 286 | + | <span class="k">branch</span>side</a> | |
| 287 | + | </li> | |
| 288 | + | <li data-k="tag v1.0.0"> | |
| 289 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 290 | + | <span class="k">tag</span>v1.0.0</a> | |
| 291 | + | </li> | |
| 292 | + | </ul> | |
| 293 | + | </dialog> | |
| 294 | + | <footer class="bar status" aria-label="Status"> | |
| 295 | + | <span class="mode">commit</span> | |
| 296 | + | <span class="st-ctx">SHA7 · 1 file · +2600 -0</span> | |
| 297 | + | <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'))"> | |
| 298 | + | <span class="hi">:</span> go to…</button> | |
| 299 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 300 | + | </span> | |
| 301 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 302 | + | <span class="st-src"> | |
| 303 | + | <span class="st-ver">ermite devel · </span> | |
| 304 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 305 | + | </span> | |
| 306 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 307 | + | </footer> | |
| 308 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 309 | + | if (q('dialog[open]').count) return; | |
| 310 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 311 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 312 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 313 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 314 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 315 | + | })(1); | |
| 316 | + | k-k 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-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 325 | + | 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)); | |
| 326 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 327 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 328 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 329 | + | 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()); | |
| 330 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 331 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 332 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 333 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 334 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 335 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 336 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 337 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 338 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 339 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 340 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 341 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 342 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 343 | + | k-permalink from:body -> { | |
| 344 | + | let b = document.body.dataset; | |
| 345 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 346 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 347 | + | navigator.clipboard?.writeText(location.href); | |
| 348 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 349 | + | }"> | |
| 350 | + | </div> | |
| 351 | + | </body> | |
| 352 | + | </html> | |
A cmd/ermite/testdata/web/commit-binary.html +349 -0
| @@ -0,0 +1,349 @@ | |||
| 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>SHA7 change a binary file · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>change a binary file</h1> | |
| 60 | + | <dl class="kv show-kv"> | |
| 61 | + | <dt>commit</dt> | |
| 62 | + | <dd>SHA40</dd> | |
| 63 | + | <dt>author</dt> | |
| 64 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 65 | + | <dt>parent</dt> | |
| 66 | + | <dd> | |
| 67 | + | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 68 | + | </dd> | |
| 69 | + | </dl> | |
| 70 | + | </header> | |
| 71 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 72 | + | <span class="dim">1 file · <span class="plus">+0</span> | |
| 73 | + | <span class="minus">-0</span> | |
| 74 | + | </span> | |
| 75 | + | <span class="grow"> | |
| 76 | + | </span> | |
| 77 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 78 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 79 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 80 | + | </div> | |
| 81 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 82 | + | <details class="dfile" id="f1" data-file> | |
| 83 | + | <summary> | |
| 84 | + | <span class="st">M</span> | |
| 85 | + | <span class="p">logo.bin</span> | |
| 86 | + | <span class="plus">+0</span> | |
| 87 | + | <span class="minus">-0</span> | |
| 88 | + | <span class="note">binary file: no text diff</span> | |
| 89 | + | </summary> | |
| 90 | + | <p class="note pad">binary file: no text diff</p> | |
| 91 | + | </details> | |
| 92 | + | </div> | |
| 93 | + | </main> | |
| 94 | + | <aside class="rail" aria-label="Context"> | |
| 95 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 96 | + | <summary>1 changed file</summary> | |
| 97 | + | <section aria-labelledby="r-files"> | |
| 98 | + | <h2 id="r-files">files <span>1</span> | |
| 99 | + | </h2> | |
| 100 | + | <ol class="files" aria-label="Changed files"> | |
| 101 | + | <li> | |
| 102 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 103 | + | <span class="st" title="M">M</span> | |
| 104 | + | <span class="p" title="logo.bin">logo.bin</span> | |
| 105 | + | <span class="n"> | |
| 106 | + | <span class="plus">+0</span> | |
| 107 | + | <span class="minus">-0</span> | |
| 108 | + | </span> | |
| 109 | + | <span class="b" aria-hidden="true"> | |
| 110 | + | <span class="plus"> | |
| 111 | + | </span> | |
| 112 | + | <span class="minus"> | |
| 113 | + | </span>bin</span> | |
| 114 | + | </a> | |
| 115 | + | </li> | |
| 116 | + | </ol> | |
| 117 | + | </section> | |
| 118 | + | </details> | |
| 119 | + | </aside> | |
| 120 | + | </div> | |
| 121 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 122 | + | <div class="panel-head"> | |
| 123 | + | <span>keys</span> | |
| 124 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 125 | + | </div> | |
| 126 | + | <dl class="keys"> | |
| 127 | + | <div> | |
| 128 | + | <dt> | |
| 129 | + | <kbd>j</kbd> | |
| 130 | + | <kbd>k</kbd> | |
| 131 | + | </dt> | |
| 132 | + | <dd>move cursor down / up</dd> | |
| 133 | + | </div> | |
| 134 | + | <div> | |
| 135 | + | <dt> | |
| 136 | + | <kbd>enter</kbd> | |
| 137 | + | </dt> | |
| 138 | + | <dd>open row under cursor</dd> | |
| 139 | + | </div> | |
| 140 | + | <div> | |
| 141 | + | <dt> | |
| 142 | + | <kbd>g</kbd> | |
| 143 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 144 | + | </dt> | |
| 145 | + | <dd>first / last row</dd> | |
| 146 | + | </div> | |
| 147 | + | <div> | |
| 148 | + | <dt> | |
| 149 | + | <kbd>/</kbd> | |
| 150 | + | </dt> | |
| 151 | + | <dd>focus the filter</dd> | |
| 152 | + | </div> | |
| 153 | + | <div> | |
| 154 | + | <dt> | |
| 155 | + | <kbd>g h</kbd> | |
| 156 | + | <kbd>g t</kbd> | |
| 157 | + | </dt> | |
| 158 | + | <dd>go home / tree</dd> | |
| 159 | + | </div> | |
| 160 | + | <div> | |
| 161 | + | <dt> | |
| 162 | + | <kbd>g c</kbd> | |
| 163 | + | <kbd>g r</kbd> | |
| 164 | + | </dt> | |
| 165 | + | <dd>go commits / refs</dd> | |
| 166 | + | </div> | |
| 167 | + | <div> | |
| 168 | + | <dt> | |
| 169 | + | <kbd>b</kbd> | |
| 170 | + | </dt> | |
| 171 | + | <dd>switch ref (branches, tags)</dd> | |
| 172 | + | </div> | |
| 173 | + | <div> | |
| 174 | + | <dt> | |
| 175 | + | <kbd>:</kbd> | |
| 176 | + | <kbd>f</kbd> | |
| 177 | + | </dt> | |
| 178 | + | <dd>go to: section, ref, file, repo</dd> | |
| 179 | + | </div> | |
| 180 | + | <div> | |
| 181 | + | <dt> | |
| 182 | + | <kbd>y</kbd> | |
| 183 | + | </dt> | |
| 184 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 185 | + | </div> | |
| 186 | + | <div> | |
| 187 | + | <dt> | |
| 188 | + | <kbd>s</kbd> | |
| 189 | + | <kbd>t</kbd> | |
| 190 | + | <kbd>c</kbd> | |
| 191 | + | <kbd>r</kbd> | |
| 192 | + | </dt> | |
| 193 | + | <dd>repo sections (when shown)</dd> | |
| 194 | + | </div> | |
| 195 | + | <div> | |
| 196 | + | <dt> | |
| 197 | + | <kbd>d</kbd> | |
| 198 | + | <kbd>u</kbd> | |
| 199 | + | <kbd>x</kbd> | |
| 200 | + | </dt> | |
| 201 | + | <dd>commit: split / unified / collapse all</dd> | |
| 202 | + | </div> | |
| 203 | + | <div> | |
| 204 | + | <dt> | |
| 205 | + | <kbd>esc</kbd> | |
| 206 | + | </dt> | |
| 207 | + | <dd>close panel</dd> | |
| 208 | + | </div> | |
| 209 | + | </dl> | |
| 210 | + | </dialog> | |
| 211 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 212 | + | <div class="panel-head"> | |
| 213 | + | <label for="refs-filter">ref></label> | |
| 214 | + | <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))"> | |
| 215 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 216 | + | </div> | |
| 217 | + | <div class="panel-cols" data-picklist> | |
| 218 | + | <section aria-label="Branches"> | |
| 219 | + | <h2>branches <span class="mute">2</span> | |
| 220 | + | </h2> | |
| 221 | + | <ul> | |
| 222 | + | <li> | |
| 223 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 224 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 225 | + | </li> | |
| 226 | + | <li> | |
| 227 | + | <a href="/diffs/tree/side"> | |
| 228 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 229 | + | </li> | |
| 230 | + | </ul> | |
| 231 | + | </section> | |
| 232 | + | <section aria-label="Tags"> | |
| 233 | + | <h2>tags <span class="mute">1</span> | |
| 234 | + | </h2> | |
| 235 | + | <ul> | |
| 236 | + | <li> | |
| 237 | + | <a href="/diffs/tree/v1.0.0"> | |
| 238 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 239 | + | </li> | |
| 240 | + | </ul> | |
| 241 | + | </section> | |
| 242 | + | </div> | |
| 243 | + | </dialog> | |
| 244 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 245 | + | <div class="panel-head"> | |
| 246 | + | <label for="cmd-q" class="hi">:</label> | |
| 247 | + | <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(); | |
| 248 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 249 | + | 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(); | |
| 250 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 251 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 252 | + | 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' })) } | |
| 253 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 254 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 255 | + | </div> | |
| 256 | + | <ul id="cmd-list" class="cmd-list"> | |
| 257 | + | <li data-k="go home" data-cursor> | |
| 258 | + | <a href="/" tabindex="-1"> | |
| 259 | + | <span class="k">go</span>home</a> | |
| 260 | + | </li> | |
| 261 | + | <li data-k="section summary"> | |
| 262 | + | <a href="/diffs" tabindex="-1"> | |
| 263 | + | <span class="k">section</span>summary</a> | |
| 264 | + | </li> | |
| 265 | + | <li data-k="section tree"> | |
| 266 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 267 | + | <span class="k">section</span>tree</a> | |
| 268 | + | </li> | |
| 269 | + | <li data-k="section commits"> | |
| 270 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 271 | + | <span class="k">section</span>commits</a> | |
| 272 | + | </li> | |
| 273 | + | <li data-k="section refs"> | |
| 274 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 275 | + | <span class="k">section</span>refs</a> | |
| 276 | + | </li> | |
| 277 | + | <li data-k="branch main"> | |
| 278 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 279 | + | <span class="k">branch</span>main</a> | |
| 280 | + | </li> | |
| 281 | + | <li data-k="branch side"> | |
| 282 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 283 | + | <span class="k">branch</span>side</a> | |
| 284 | + | </li> | |
| 285 | + | <li data-k="tag v1.0.0"> | |
| 286 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 287 | + | <span class="k">tag</span>v1.0.0</a> | |
| 288 | + | </li> | |
| 289 | + | </ul> | |
| 290 | + | </dialog> | |
| 291 | + | <footer class="bar status" aria-label="Status"> | |
| 292 | + | <span class="mode">commit</span> | |
| 293 | + | <span class="st-ctx">SHA7 · 1 file · +0 -0</span> | |
| 294 | + | <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'))"> | |
| 295 | + | <span class="hi">:</span> go to…</button> | |
| 296 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 297 | + | </span> | |
| 298 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 299 | + | <span class="st-src"> | |
| 300 | + | <span class="st-ver">ermite devel · </span> | |
| 301 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 302 | + | </span> | |
| 303 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 304 | + | </footer> | |
| 305 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 306 | + | if (q('dialog[open]').count) return; | |
| 307 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 308 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 309 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 310 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 311 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 312 | + | })(1); | |
| 313 | + | k-k from:body -> ((d) => { | |
| 314 | + | if (q('dialog[open]').count) return; | |
| 315 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 316 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 317 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 318 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 319 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 320 | + | })(-1); | |
| 321 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 322 | + | 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)); | |
| 323 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 324 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 325 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 326 | + | 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()); | |
| 327 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 328 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 329 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 330 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 331 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 332 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 333 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 334 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 335 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 336 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 337 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 338 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 339 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 340 | + | k-permalink from:body -> { | |
| 341 | + | let b = document.body.dataset; | |
| 342 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 343 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 344 | + | navigator.clipboard?.writeText(location.href); | |
| 345 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 346 | + | }"> | |
| 347 | + | </div> | |
| 348 | + | </body> | |
| 349 | + | </html> | |
A cmd/ermite/testdata/web/commit-merge.html +380 -0
| @@ -0,0 +1,380 @@ | |||
| 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>SHA7 merge the side branch · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>merge the side branch</h1> | |
| 60 | + | <dl class="kv show-kv"> | |
| 61 | + | <dt>commit</dt> | |
| 62 | + | <dd>SHA40</dd> | |
| 63 | + | <dt>author</dt> | |
| 64 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 65 | + | <dt>merge of</dt> | |
| 66 | + | <dd> | |
| 67 | + | <a href="/diffs/commit/SHA40">SHA7</a> + <a href="/diffs/commit/SHA40">SHA7</a> · diff against the first parent</dd> | |
| 68 | + | </dl> | |
| 69 | + | </header> | |
| 70 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 71 | + | <span class="dim">1 file · <span class="plus">+1</span> | |
| 72 | + | <span class="minus">-0</span> | |
| 73 | + | </span> | |
| 74 | + | <span class="grow"> | |
| 75 | + | </span> | |
| 76 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 77 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 78 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 79 | + | </div> | |
| 80 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 81 | + | <details class="dfile" id="f1" data-file open> | |
| 82 | + | <summary> | |
| 83 | + | <span class="st">A</span> | |
| 84 | + | <span class="p">side.txt</span> | |
| 85 | + | <span class="plus">+1</span> | |
| 86 | + | <span class="minus">-0</span> | |
| 87 | + | </summary> | |
| 88 | + | <div class="dscroll"> | |
| 89 | + | <table class="dt uni" aria-label="side.txt, unified"> | |
| 90 | + | <tbody> | |
| 91 | + | <tr class="hunk"> | |
| 92 | + | <td colspan="4">@@ -0,0 +1,1 @@</td> | |
| 93 | + | </tr> | |
| 94 | + | <tr class="add"> | |
| 95 | + | <td class="ln"> | |
| 96 | + | </td> | |
| 97 | + | <td class="ln">1</td> | |
| 98 | + | <td class="m">+</td> | |
| 99 | + | <td class="tx">from the side</td> | |
| 100 | + | </tr> | |
| 101 | + | </tbody> | |
| 102 | + | </table> | |
| 103 | + | <table class="dt spl" aria-label="side.txt, split"> | |
| 104 | + | <tbody> | |
| 105 | + | <tr class="hunk"> | |
| 106 | + | <td colspan="6">@@ -0,0 +1,1 @@</td> | |
| 107 | + | </tr> | |
| 108 | + | <tr> | |
| 109 | + | <td class="ln none"> | |
| 110 | + | </td> | |
| 111 | + | <td class="m none"> | |
| 112 | + | </td> | |
| 113 | + | <td class="tx none"> | |
| 114 | + | </td> | |
| 115 | + | <td class="ln add">1</td> | |
| 116 | + | <td class="m add">+</td> | |
| 117 | + | <td class="tx add">from the side</td> | |
| 118 | + | </tr> | |
| 119 | + | </tbody> | |
| 120 | + | </table> | |
| 121 | + | </div> | |
| 122 | + | </details> | |
| 123 | + | </div> | |
| 124 | + | </main> | |
| 125 | + | <aside class="rail" aria-label="Context"> | |
| 126 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 127 | + | <summary>1 changed file</summary> | |
| 128 | + | <section aria-labelledby="r-files"> | |
| 129 | + | <h2 id="r-files">files <span>1</span> | |
| 130 | + | </h2> | |
| 131 | + | <ol class="files" aria-label="Changed files"> | |
| 132 | + | <li> | |
| 133 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 134 | + | <span class="st" title="A">A</span> | |
| 135 | + | <span class="p" title="side.txt">side.txt</span> | |
| 136 | + | <span class="n"> | |
| 137 | + | <span class="plus">+1</span> | |
| 138 | + | <span class="minus">-0</span> | |
| 139 | + | </span> | |
| 140 | + | <span class="b" aria-hidden="true"> | |
| 141 | + | <span class="plus">++++++++++</span> | |
| 142 | + | <span class="minus"> | |
| 143 | + | </span> | |
| 144 | + | </span> | |
| 145 | + | </a> | |
| 146 | + | </li> | |
| 147 | + | </ol> | |
| 148 | + | </section> | |
| 149 | + | </details> | |
| 150 | + | </aside> | |
| 151 | + | </div> | |
| 152 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 153 | + | <div class="panel-head"> | |
| 154 | + | <span>keys</span> | |
| 155 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 156 | + | </div> | |
| 157 | + | <dl class="keys"> | |
| 158 | + | <div> | |
| 159 | + | <dt> | |
| 160 | + | <kbd>j</kbd> | |
| 161 | + | <kbd>k</kbd> | |
| 162 | + | </dt> | |
| 163 | + | <dd>move cursor down / up</dd> | |
| 164 | + | </div> | |
| 165 | + | <div> | |
| 166 | + | <dt> | |
| 167 | + | <kbd>enter</kbd> | |
| 168 | + | </dt> | |
| 169 | + | <dd>open row under cursor</dd> | |
| 170 | + | </div> | |
| 171 | + | <div> | |
| 172 | + | <dt> | |
| 173 | + | <kbd>g</kbd> | |
| 174 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 175 | + | </dt> | |
| 176 | + | <dd>first / last row</dd> | |
| 177 | + | </div> | |
| 178 | + | <div> | |
| 179 | + | <dt> | |
| 180 | + | <kbd>/</kbd> | |
| 181 | + | </dt> | |
| 182 | + | <dd>focus the filter</dd> | |
| 183 | + | </div> | |
| 184 | + | <div> | |
| 185 | + | <dt> | |
| 186 | + | <kbd>g h</kbd> | |
| 187 | + | <kbd>g t</kbd> | |
| 188 | + | </dt> | |
| 189 | + | <dd>go home / tree</dd> | |
| 190 | + | </div> | |
| 191 | + | <div> | |
| 192 | + | <dt> | |
| 193 | + | <kbd>g c</kbd> | |
| 194 | + | <kbd>g r</kbd> | |
| 195 | + | </dt> | |
| 196 | + | <dd>go commits / refs</dd> | |
| 197 | + | </div> | |
| 198 | + | <div> | |
| 199 | + | <dt> | |
| 200 | + | <kbd>b</kbd> | |
| 201 | + | </dt> | |
| 202 | + | <dd>switch ref (branches, tags)</dd> | |
| 203 | + | </div> | |
| 204 | + | <div> | |
| 205 | + | <dt> | |
| 206 | + | <kbd>:</kbd> | |
| 207 | + | <kbd>f</kbd> | |
| 208 | + | </dt> | |
| 209 | + | <dd>go to: section, ref, file, repo</dd> | |
| 210 | + | </div> | |
| 211 | + | <div> | |
| 212 | + | <dt> | |
| 213 | + | <kbd>y</kbd> | |
| 214 | + | </dt> | |
| 215 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 216 | + | </div> | |
| 217 | + | <div> | |
| 218 | + | <dt> | |
| 219 | + | <kbd>s</kbd> | |
| 220 | + | <kbd>t</kbd> | |
| 221 | + | <kbd>c</kbd> | |
| 222 | + | <kbd>r</kbd> | |
| 223 | + | </dt> | |
| 224 | + | <dd>repo sections (when shown)</dd> | |
| 225 | + | </div> | |
| 226 | + | <div> | |
| 227 | + | <dt> | |
| 228 | + | <kbd>d</kbd> | |
| 229 | + | <kbd>u</kbd> | |
| 230 | + | <kbd>x</kbd> | |
| 231 | + | </dt> | |
| 232 | + | <dd>commit: split / unified / collapse all</dd> | |
| 233 | + | </div> | |
| 234 | + | <div> | |
| 235 | + | <dt> | |
| 236 | + | <kbd>esc</kbd> | |
| 237 | + | </dt> | |
| 238 | + | <dd>close panel</dd> | |
| 239 | + | </div> | |
| 240 | + | </dl> | |
| 241 | + | </dialog> | |
| 242 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 243 | + | <div class="panel-head"> | |
| 244 | + | <label for="refs-filter">ref></label> | |
| 245 | + | <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))"> | |
| 246 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 247 | + | </div> | |
| 248 | + | <div class="panel-cols" data-picklist> | |
| 249 | + | <section aria-label="Branches"> | |
| 250 | + | <h2>branches <span class="mute">2</span> | |
| 251 | + | </h2> | |
| 252 | + | <ul> | |
| 253 | + | <li> | |
| 254 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 255 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 256 | + | </li> | |
| 257 | + | <li> | |
| 258 | + | <a href="/diffs/tree/side"> | |
| 259 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 260 | + | </li> | |
| 261 | + | </ul> | |
| 262 | + | </section> | |
| 263 | + | <section aria-label="Tags"> | |
| 264 | + | <h2>tags <span class="mute">1</span> | |
| 265 | + | </h2> | |
| 266 | + | <ul> | |
| 267 | + | <li> | |
| 268 | + | <a href="/diffs/tree/v1.0.0"> | |
| 269 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 270 | + | </li> | |
| 271 | + | </ul> | |
| 272 | + | </section> | |
| 273 | + | </div> | |
| 274 | + | </dialog> | |
| 275 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 276 | + | <div class="panel-head"> | |
| 277 | + | <label for="cmd-q" class="hi">:</label> | |
| 278 | + | <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(); | |
| 279 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 280 | + | 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(); | |
| 281 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 282 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 283 | + | 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' })) } | |
| 284 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 285 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 286 | + | </div> | |
| 287 | + | <ul id="cmd-list" class="cmd-list"> | |
| 288 | + | <li data-k="go home" data-cursor> | |
| 289 | + | <a href="/" tabindex="-1"> | |
| 290 | + | <span class="k">go</span>home</a> | |
| 291 | + | </li> | |
| 292 | + | <li data-k="section summary"> | |
| 293 | + | <a href="/diffs" tabindex="-1"> | |
| 294 | + | <span class="k">section</span>summary</a> | |
| 295 | + | </li> | |
| 296 | + | <li data-k="section tree"> | |
| 297 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 298 | + | <span class="k">section</span>tree</a> | |
| 299 | + | </li> | |
| 300 | + | <li data-k="section commits"> | |
| 301 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 302 | + | <span class="k">section</span>commits</a> | |
| 303 | + | </li> | |
| 304 | + | <li data-k="section refs"> | |
| 305 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 306 | + | <span class="k">section</span>refs</a> | |
| 307 | + | </li> | |
| 308 | + | <li data-k="branch main"> | |
| 309 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 310 | + | <span class="k">branch</span>main</a> | |
| 311 | + | </li> | |
| 312 | + | <li data-k="branch side"> | |
| 313 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 314 | + | <span class="k">branch</span>side</a> | |
| 315 | + | </li> | |
| 316 | + | <li data-k="tag v1.0.0"> | |
| 317 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 318 | + | <span class="k">tag</span>v1.0.0</a> | |
| 319 | + | </li> | |
| 320 | + | </ul> | |
| 321 | + | </dialog> | |
| 322 | + | <footer class="bar status" aria-label="Status"> | |
| 323 | + | <span class="mode">commit</span> | |
| 324 | + | <span class="st-ctx">SHA7 · 1 file · +1 -0</span> | |
| 325 | + | <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'))"> | |
| 326 | + | <span class="hi">:</span> go to…</button> | |
| 327 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 328 | + | </span> | |
| 329 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 330 | + | <span class="st-src"> | |
| 331 | + | <span class="st-ver">ermite devel · </span> | |
| 332 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 333 | + | </span> | |
| 334 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 335 | + | </footer> | |
| 336 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 337 | + | if (q('dialog[open]').count) return; | |
| 338 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 339 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 340 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 341 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 342 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 343 | + | })(1); | |
| 344 | + | k-k from:body -> ((d) => { | |
| 345 | + | if (q('dialog[open]').count) return; | |
| 346 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 347 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 348 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 349 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 350 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 351 | + | })(-1); | |
| 352 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 353 | + | 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)); | |
| 354 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 355 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 356 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 357 | + | 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()); | |
| 358 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 359 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 360 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 361 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 362 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 363 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 364 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 365 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 366 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 367 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 368 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 369 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 370 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 371 | + | k-permalink from:body -> { | |
| 372 | + | let b = document.body.dataset; | |
| 373 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 374 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 375 | + | navigator.clipboard?.writeText(location.href); | |
| 376 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 377 | + | }"> | |
| 378 | + | </div> | |
| 379 | + | </body> | |
| 380 | + | </html> | |
A cmd/ermite/testdata/web/commit-modify-split.html +499 -0
| @@ -0,0 +1,499 @@ | |||
| 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>SHA7 modify a file and make a script executable · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>modify a file and make a script executable</h1> | |
| 60 | + | <pre class="msg">The body has a second paragraph. | |
| 61 | + | And <b>html</b> & "quotes".</pre> | |
| 62 | + | <dl class="kv show-kv"> | |
| 63 | + | <dt>commit</dt> | |
| 64 | + | <dd>SHA40</dd> | |
| 65 | + | <dt>author</dt> | |
| 66 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 67 | + | <dt>parent</dt> | |
| 68 | + | <dd> | |
| 69 | + | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 70 | + | </dd> | |
| 71 | + | <dt>refs</dt> | |
| 72 | + | <dd>[tag: v1.0.0] </dd> | |
| 73 | + | </dl> | |
| 74 | + | </header> | |
| 75 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 76 | + | <span class="dim">2 files · <span class="plus">+2</span> | |
| 77 | + | <span class="minus">-1</span> | |
| 78 | + | </span> | |
| 79 | + | <span class="grow"> | |
| 80 | + | </span> | |
| 81 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 82 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 83 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 84 | + | </div> | |
| 85 | + | <div id="diffs" data-diff="split"> | |
| 86 | + | <details class="dfile" id="f1" data-file open> | |
| 87 | + | <summary> | |
| 88 | + | <span class="st">M</span> | |
| 89 | + | <span class="p">a.txt</span> | |
| 90 | + | <span class="plus">+2</span> | |
| 91 | + | <span class="minus">-1</span> | |
| 92 | + | </summary> | |
| 93 | + | <div class="dscroll"> | |
| 94 | + | <table class="dt uni" aria-label="a.txt, unified"> | |
| 95 | + | <tbody> | |
| 96 | + | <tr class="hunk"> | |
| 97 | + | <td colspan="4">@@ -1,5 +1,6 @@</td> | |
| 98 | + | </tr> | |
| 99 | + | <tr class="ctx"> | |
| 100 | + | <td class="ln">1</td> | |
| 101 | + | <td class="ln">1</td> | |
| 102 | + | <td class="m"> | |
| 103 | + | </td> | |
| 104 | + | <td class="tx">one</td> | |
| 105 | + | </tr> | |
| 106 | + | <tr class="del"> | |
| 107 | + | <td class="ln">2</td> | |
| 108 | + | <td class="ln"> | |
| 109 | + | </td> | |
| 110 | + | <td class="m">-</td> | |
| 111 | + | <td class="tx">two</td> | |
| 112 | + | </tr> | |
| 113 | + | <tr class="add"> | |
| 114 | + | <td class="ln"> | |
| 115 | + | </td> | |
| 116 | + | <td class="ln">2</td> | |
| 117 | + | <td class="m">+</td> | |
| 118 | + | <td class="tx">2</td> | |
| 119 | + | </tr> | |
| 120 | + | <tr class="ctx"> | |
| 121 | + | <td class="ln">3</td> | |
| 122 | + | <td class="ln">3</td> | |
| 123 | + | <td class="m"> | |
| 124 | + | </td> | |
| 125 | + | <td class="tx">three</td> | |
| 126 | + | </tr> | |
| 127 | + | <tr class="ctx"> | |
| 128 | + | <td class="ln">4</td> | |
| 129 | + | <td class="ln">4</td> | |
| 130 | + | <td class="m"> | |
| 131 | + | </td> | |
| 132 | + | <td class="tx">four</td> | |
| 133 | + | </tr> | |
| 134 | + | <tr class="ctx"> | |
| 135 | + | <td class="ln">5</td> | |
| 136 | + | <td class="ln">5</td> | |
| 137 | + | <td class="m"> | |
| 138 | + | </td> | |
| 139 | + | <td class="tx">five</td> | |
| 140 | + | </tr> | |
| 141 | + | <tr class="add"> | |
| 142 | + | <td class="ln"> | |
| 143 | + | </td> | |
| 144 | + | <td class="ln">6</td> | |
| 145 | + | <td class="m">+</td> | |
| 146 | + | <td class="tx">six</td> | |
| 147 | + | </tr> | |
| 148 | + | </tbody> | |
| 149 | + | </table> | |
| 150 | + | <table class="dt spl" aria-label="a.txt, split"> | |
| 151 | + | <tbody> | |
| 152 | + | <tr class="hunk"> | |
| 153 | + | <td colspan="6">@@ -1,5 +1,6 @@</td> | |
| 154 | + | </tr> | |
| 155 | + | <tr> | |
| 156 | + | <td class="ln ctx">1</td> | |
| 157 | + | <td class="m ctx"> | |
| 158 | + | </td> | |
| 159 | + | <td class="tx ctx">one</td> | |
| 160 | + | <td class="ln ctx">1</td> | |
| 161 | + | <td class="m ctx"> | |
| 162 | + | </td> | |
| 163 | + | <td class="tx ctx">one</td> | |
| 164 | + | </tr> | |
| 165 | + | <tr> | |
| 166 | + | <td class="ln del">2</td> | |
| 167 | + | <td class="m del">-</td> | |
| 168 | + | <td class="tx del">two</td> | |
| 169 | + | <td class="ln add">2</td> | |
| 170 | + | <td class="m add">+</td> | |
| 171 | + | <td class="tx add">2</td> | |
| 172 | + | </tr> | |
| 173 | + | <tr> | |
| 174 | + | <td class="ln ctx">3</td> | |
| 175 | + | <td class="m ctx"> | |
| 176 | + | </td> | |
| 177 | + | <td class="tx ctx">three</td> | |
| 178 | + | <td class="ln ctx">3</td> | |
| 179 | + | <td class="m ctx"> | |
| 180 | + | </td> | |
| 181 | + | <td class="tx ctx">three</td> | |
| 182 | + | </tr> | |
| 183 | + | <tr> | |
| 184 | + | <td class="ln ctx">4</td> | |
| 185 | + | <td class="m ctx"> | |
| 186 | + | </td> | |
| 187 | + | <td class="tx ctx">four</td> | |
| 188 | + | <td class="ln ctx">4</td> | |
| 189 | + | <td class="m ctx"> | |
| 190 | + | </td> | |
| 191 | + | <td class="tx ctx">four</td> | |
| 192 | + | </tr> | |
| 193 | + | <tr> | |
| 194 | + | <td class="ln ctx">5</td> | |
| 195 | + | <td class="m ctx"> | |
| 196 | + | </td> | |
| 197 | + | <td class="tx ctx">five</td> | |
| 198 | + | <td class="ln ctx">5</td> | |
| 199 | + | <td class="m ctx"> | |
| 200 | + | </td> | |
| 201 | + | <td class="tx ctx">five</td> | |
| 202 | + | </tr> | |
| 203 | + | <tr> | |
| 204 | + | <td class="ln none"> | |
| 205 | + | </td> | |
| 206 | + | <td class="m none"> | |
| 207 | + | </td> | |
| 208 | + | <td class="tx none"> | |
| 209 | + | </td> | |
| 210 | + | <td class="ln add">6</td> | |
| 211 | + | <td class="m add">+</td> | |
| 212 | + | <td class="tx add">six</td> | |
| 213 | + | </tr> | |
| 214 | + | </tbody> | |
| 215 | + | </table> | |
| 216 | + | </div> | |
| 217 | + | </details> | |
| 218 | + | <details class="dfile" id="f2" data-file> | |
| 219 | + | <summary> | |
| 220 | + | <span class="st">M</span> | |
| 221 | + | <span class="p">run.sh</span> | |
| 222 | + | <span class="plus">+0</span> | |
| 223 | + | <span class="minus">-0</span> | |
| 224 | + | <span class="note">mode 100644 → 100755</span> | |
| 225 | + | </summary> | |
| 226 | + | <p class="note pad">mode 100644 → 100755</p> | |
| 227 | + | </details> | |
| 228 | + | </div> | |
| 229 | + | </main> | |
| 230 | + | <aside class="rail" aria-label="Context"> | |
| 231 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 232 | + | <summary>2 changed files</summary> | |
| 233 | + | <section aria-labelledby="r-files"> | |
| 234 | + | <h2 id="r-files">files <span>2</span> | |
| 235 | + | </h2> | |
| 236 | + | <ol class="files" aria-label="Changed files"> | |
| 237 | + | <li> | |
| 238 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 239 | + | <span class="st" title="M">M</span> | |
| 240 | + | <span class="p" title="a.txt">a.txt</span> | |
| 241 | + | <span class="n"> | |
| 242 | + | <span class="plus">+2</span> | |
| 243 | + | <span class="minus">-1</span> | |
| 244 | + | </span> | |
| 245 | + | <span class="b" aria-hidden="true"> | |
| 246 | + | <span class="plus">+++++++</span> | |
| 247 | + | <span class="minus">---</span> | |
| 248 | + | </span> | |
| 249 | + | </a> | |
| 250 | + | </li> | |
| 251 | + | <li> | |
| 252 | + | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 253 | + | <span class="st" title="M">M</span> | |
| 254 | + | <span class="p" title="run.sh">run.sh</span> | |
| 255 | + | <span class="n"> | |
| 256 | + | <span class="plus">+0</span> | |
| 257 | + | <span class="minus">-0</span> | |
| 258 | + | </span> | |
| 259 | + | <span class="b" aria-hidden="true"> | |
| 260 | + | <span class="plus"> | |
| 261 | + | </span> | |
| 262 | + | <span class="minus"> | |
| 263 | + | </span>··········</span> | |
| 264 | + | </a> | |
| 265 | + | </li> | |
| 266 | + | </ol> | |
| 267 | + | </section> | |
| 268 | + | </details> | |
| 269 | + | </aside> | |
| 270 | + | </div> | |
| 271 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 272 | + | <div class="panel-head"> | |
| 273 | + | <span>keys</span> | |
| 274 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 275 | + | </div> | |
| 276 | + | <dl class="keys"> | |
| 277 | + | <div> | |
| 278 | + | <dt> | |
| 279 | + | <kbd>j</kbd> | |
| 280 | + | <kbd>k</kbd> | |
| 281 | + | </dt> | |
| 282 | + | <dd>move cursor down / up</dd> | |
| 283 | + | </div> | |
| 284 | + | <div> | |
| 285 | + | <dt> | |
| 286 | + | <kbd>enter</kbd> | |
| 287 | + | </dt> | |
| 288 | + | <dd>open row under cursor</dd> | |
| 289 | + | </div> | |
| 290 | + | <div> | |
| 291 | + | <dt> | |
| 292 | + | <kbd>g</kbd> | |
| 293 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 294 | + | </dt> | |
| 295 | + | <dd>first / last row</dd> | |
| 296 | + | </div> | |
| 297 | + | <div> | |
| 298 | + | <dt> | |
| 299 | + | <kbd>/</kbd> | |
| 300 | + | </dt> | |
| 301 | + | <dd>focus the filter</dd> | |
| 302 | + | </div> | |
| 303 | + | <div> | |
| 304 | + | <dt> | |
| 305 | + | <kbd>g h</kbd> | |
| 306 | + | <kbd>g t</kbd> | |
| 307 | + | </dt> | |
| 308 | + | <dd>go home / tree</dd> | |
| 309 | + | </div> | |
| 310 | + | <div> | |
| 311 | + | <dt> | |
| 312 | + | <kbd>g c</kbd> | |
| 313 | + | <kbd>g r</kbd> | |
| 314 | + | </dt> | |
| 315 | + | <dd>go commits / refs</dd> | |
| 316 | + | </div> | |
| 317 | + | <div> | |
| 318 | + | <dt> | |
| 319 | + | <kbd>b</kbd> | |
| 320 | + | </dt> | |
| 321 | + | <dd>switch ref (branches, tags)</dd> | |
| 322 | + | </div> | |
| 323 | + | <div> | |
| 324 | + | <dt> | |
| 325 | + | <kbd>:</kbd> | |
| 326 | + | <kbd>f</kbd> | |
| 327 | + | </dt> | |
| 328 | + | <dd>go to: section, ref, file, repo</dd> | |
| 329 | + | </div> | |
| 330 | + | <div> | |
| 331 | + | <dt> | |
| 332 | + | <kbd>y</kbd> | |
| 333 | + | </dt> | |
| 334 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 335 | + | </div> | |
| 336 | + | <div> | |
| 337 | + | <dt> | |
| 338 | + | <kbd>s</kbd> | |
| 339 | + | <kbd>t</kbd> | |
| 340 | + | <kbd>c</kbd> | |
| 341 | + | <kbd>r</kbd> | |
| 342 | + | </dt> | |
| 343 | + | <dd>repo sections (when shown)</dd> | |
| 344 | + | </div> | |
| 345 | + | <div> | |
| 346 | + | <dt> | |
| 347 | + | <kbd>d</kbd> | |
| 348 | + | <kbd>u</kbd> | |
| 349 | + | <kbd>x</kbd> | |
| 350 | + | </dt> | |
| 351 | + | <dd>commit: split / unified / collapse all</dd> | |
| 352 | + | </div> | |
| 353 | + | <div> | |
| 354 | + | <dt> | |
| 355 | + | <kbd>esc</kbd> | |
| 356 | + | </dt> | |
| 357 | + | <dd>close panel</dd> | |
| 358 | + | </div> | |
| 359 | + | </dl> | |
| 360 | + | </dialog> | |
| 361 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 362 | + | <div class="panel-head"> | |
| 363 | + | <label for="refs-filter">ref></label> | |
| 364 | + | <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))"> | |
| 365 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 366 | + | </div> | |
| 367 | + | <div class="panel-cols" data-picklist> | |
| 368 | + | <section aria-label="Branches"> | |
| 369 | + | <h2>branches <span class="mute">2</span> | |
| 370 | + | </h2> | |
| 371 | + | <ul> | |
| 372 | + | <li> | |
| 373 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 374 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 375 | + | </li> | |
| 376 | + | <li> | |
| 377 | + | <a href="/diffs/tree/side"> | |
| 378 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 379 | + | </li> | |
| 380 | + | </ul> | |
| 381 | + | </section> | |
| 382 | + | <section aria-label="Tags"> | |
| 383 | + | <h2>tags <span class="mute">1</span> | |
| 384 | + | </h2> | |
| 385 | + | <ul> | |
| 386 | + | <li> | |
| 387 | + | <a href="/diffs/tree/v1.0.0"> | |
| 388 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 389 | + | </li> | |
| 390 | + | </ul> | |
| 391 | + | </section> | |
| 392 | + | </div> | |
| 393 | + | </dialog> | |
| 394 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 395 | + | <div class="panel-head"> | |
| 396 | + | <label for="cmd-q" class="hi">:</label> | |
| 397 | + | <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(); | |
| 398 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 399 | + | 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(); | |
| 400 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 401 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 402 | + | 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' })) } | |
| 403 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 404 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 405 | + | </div> | |
| 406 | + | <ul id="cmd-list" class="cmd-list"> | |
| 407 | + | <li data-k="go home" data-cursor> | |
| 408 | + | <a href="/" tabindex="-1"> | |
| 409 | + | <span class="k">go</span>home</a> | |
| 410 | + | </li> | |
| 411 | + | <li data-k="section summary"> | |
| 412 | + | <a href="/diffs" tabindex="-1"> | |
| 413 | + | <span class="k">section</span>summary</a> | |
| 414 | + | </li> | |
| 415 | + | <li data-k="section tree"> | |
| 416 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 417 | + | <span class="k">section</span>tree</a> | |
| 418 | + | </li> | |
| 419 | + | <li data-k="section commits"> | |
| 420 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 421 | + | <span class="k">section</span>commits</a> | |
| 422 | + | </li> | |
| 423 | + | <li data-k="section refs"> | |
| 424 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 425 | + | <span class="k">section</span>refs</a> | |
| 426 | + | </li> | |
| 427 | + | <li data-k="branch main"> | |
| 428 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 429 | + | <span class="k">branch</span>main</a> | |
| 430 | + | </li> | |
| 431 | + | <li data-k="branch side"> | |
| 432 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 433 | + | <span class="k">branch</span>side</a> | |
| 434 | + | </li> | |
| 435 | + | <li data-k="tag v1.0.0"> | |
| 436 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 437 | + | <span class="k">tag</span>v1.0.0</a> | |
| 438 | + | </li> | |
| 439 | + | </ul> | |
| 440 | + | </dialog> | |
| 441 | + | <footer class="bar status" aria-label="Status"> | |
| 442 | + | <span class="mode">commit</span> | |
| 443 | + | <span class="st-ctx">SHA7 · 2 files · +2 -1</span> | |
| 444 | + | <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'))"> | |
| 445 | + | <span class="hi">:</span> go to…</button> | |
| 446 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 447 | + | </span> | |
| 448 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 449 | + | <span class="st-src"> | |
| 450 | + | <span class="st-ver">ermite devel · </span> | |
| 451 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 452 | + | </span> | |
| 453 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 454 | + | </footer> | |
| 455 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 456 | + | if (q('dialog[open]').count) return; | |
| 457 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 458 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 459 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 460 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 461 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 462 | + | })(1); | |
| 463 | + | k-k from:body -> ((d) => { | |
| 464 | + | if (q('dialog[open]').count) return; | |
| 465 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 466 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 467 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 468 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 469 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 470 | + | })(-1); | |
| 471 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 472 | + | 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)); | |
| 473 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 474 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 475 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 476 | + | 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()); | |
| 477 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 478 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 479 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 480 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 481 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 482 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 483 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 484 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 485 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 486 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 487 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 488 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 489 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 490 | + | k-permalink from:body -> { | |
| 491 | + | let b = document.body.dataset; | |
| 492 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 493 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 494 | + | navigator.clipboard?.writeText(location.href); | |
| 495 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 496 | + | }"> | |
| 497 | + | </div> | |
| 498 | + | </body> | |
| 499 | + | </html> | |
A cmd/ermite/testdata/web/commit-modify.html +499 -0
| @@ -0,0 +1,499 @@ | |||
| 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>SHA7 modify a file and make a script executable · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>modify a file and make a script executable</h1> | |
| 60 | + | <pre class="msg">The body has a second paragraph. | |
| 61 | + | And <b>html</b> & "quotes".</pre> | |
| 62 | + | <dl class="kv show-kv"> | |
| 63 | + | <dt>commit</dt> | |
| 64 | + | <dd>SHA40</dd> | |
| 65 | + | <dt>author</dt> | |
| 66 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 67 | + | <dt>parent</dt> | |
| 68 | + | <dd> | |
| 69 | + | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 70 | + | </dd> | |
| 71 | + | <dt>refs</dt> | |
| 72 | + | <dd>[tag: v1.0.0] </dd> | |
| 73 | + | </dl> | |
| 74 | + | </header> | |
| 75 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 76 | + | <span class="dim">2 files · <span class="plus">+2</span> | |
| 77 | + | <span class="minus">-1</span> | |
| 78 | + | </span> | |
| 79 | + | <span class="grow"> | |
| 80 | + | </span> | |
| 81 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 82 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 83 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 84 | + | </div> | |
| 85 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 86 | + | <details class="dfile" id="f1" data-file open> | |
| 87 | + | <summary> | |
| 88 | + | <span class="st">M</span> | |
| 89 | + | <span class="p">a.txt</span> | |
| 90 | + | <span class="plus">+2</span> | |
| 91 | + | <span class="minus">-1</span> | |
| 92 | + | </summary> | |
| 93 | + | <div class="dscroll"> | |
| 94 | + | <table class="dt uni" aria-label="a.txt, unified"> | |
| 95 | + | <tbody> | |
| 96 | + | <tr class="hunk"> | |
| 97 | + | <td colspan="4">@@ -1,5 +1,6 @@</td> | |
| 98 | + | </tr> | |
| 99 | + | <tr class="ctx"> | |
| 100 | + | <td class="ln">1</td> | |
| 101 | + | <td class="ln">1</td> | |
| 102 | + | <td class="m"> | |
| 103 | + | </td> | |
| 104 | + | <td class="tx">one</td> | |
| 105 | + | </tr> | |
| 106 | + | <tr class="del"> | |
| 107 | + | <td class="ln">2</td> | |
| 108 | + | <td class="ln"> | |
| 109 | + | </td> | |
| 110 | + | <td class="m">-</td> | |
| 111 | + | <td class="tx">two</td> | |
| 112 | + | </tr> | |
| 113 | + | <tr class="add"> | |
| 114 | + | <td class="ln"> | |
| 115 | + | </td> | |
| 116 | + | <td class="ln">2</td> | |
| 117 | + | <td class="m">+</td> | |
| 118 | + | <td class="tx">2</td> | |
| 119 | + | </tr> | |
| 120 | + | <tr class="ctx"> | |
| 121 | + | <td class="ln">3</td> | |
| 122 | + | <td class="ln">3</td> | |
| 123 | + | <td class="m"> | |
| 124 | + | </td> | |
| 125 | + | <td class="tx">three</td> | |
| 126 | + | </tr> | |
| 127 | + | <tr class="ctx"> | |
| 128 | + | <td class="ln">4</td> | |
| 129 | + | <td class="ln">4</td> | |
| 130 | + | <td class="m"> | |
| 131 | + | </td> | |
| 132 | + | <td class="tx">four</td> | |
| 133 | + | </tr> | |
| 134 | + | <tr class="ctx"> | |
| 135 | + | <td class="ln">5</td> | |
| 136 | + | <td class="ln">5</td> | |
| 137 | + | <td class="m"> | |
| 138 | + | </td> | |
| 139 | + | <td class="tx">five</td> | |
| 140 | + | </tr> | |
| 141 | + | <tr class="add"> | |
| 142 | + | <td class="ln"> | |
| 143 | + | </td> | |
| 144 | + | <td class="ln">6</td> | |
| 145 | + | <td class="m">+</td> | |
| 146 | + | <td class="tx">six</td> | |
| 147 | + | </tr> | |
| 148 | + | </tbody> | |
| 149 | + | </table> | |
| 150 | + | <table class="dt spl" aria-label="a.txt, split"> | |
| 151 | + | <tbody> | |
| 152 | + | <tr class="hunk"> | |
| 153 | + | <td colspan="6">@@ -1,5 +1,6 @@</td> | |
| 154 | + | </tr> | |
| 155 | + | <tr> | |
| 156 | + | <td class="ln ctx">1</td> | |
| 157 | + | <td class="m ctx"> | |
| 158 | + | </td> | |
| 159 | + | <td class="tx ctx">one</td> | |
| 160 | + | <td class="ln ctx">1</td> | |
| 161 | + | <td class="m ctx"> | |
| 162 | + | </td> | |
| 163 | + | <td class="tx ctx">one</td> | |
| 164 | + | </tr> | |
| 165 | + | <tr> | |
| 166 | + | <td class="ln del">2</td> | |
| 167 | + | <td class="m del">-</td> | |
| 168 | + | <td class="tx del">two</td> | |
| 169 | + | <td class="ln add">2</td> | |
| 170 | + | <td class="m add">+</td> | |
| 171 | + | <td class="tx add">2</td> | |
| 172 | + | </tr> | |
| 173 | + | <tr> | |
| 174 | + | <td class="ln ctx">3</td> | |
| 175 | + | <td class="m ctx"> | |
| 176 | + | </td> | |
| 177 | + | <td class="tx ctx">three</td> | |
| 178 | + | <td class="ln ctx">3</td> | |
| 179 | + | <td class="m ctx"> | |
| 180 | + | </td> | |
| 181 | + | <td class="tx ctx">three</td> | |
| 182 | + | </tr> | |
| 183 | + | <tr> | |
| 184 | + | <td class="ln ctx">4</td> | |
| 185 | + | <td class="m ctx"> | |
| 186 | + | </td> | |
| 187 | + | <td class="tx ctx">four</td> | |
| 188 | + | <td class="ln ctx">4</td> | |
| 189 | + | <td class="m ctx"> | |
| 190 | + | </td> | |
| 191 | + | <td class="tx ctx">four</td> | |
| 192 | + | </tr> | |
| 193 | + | <tr> | |
| 194 | + | <td class="ln ctx">5</td> | |
| 195 | + | <td class="m ctx"> | |
| 196 | + | </td> | |
| 197 | + | <td class="tx ctx">five</td> | |
| 198 | + | <td class="ln ctx">5</td> | |
| 199 | + | <td class="m ctx"> | |
| 200 | + | </td> | |
| 201 | + | <td class="tx ctx">five</td> | |
| 202 | + | </tr> | |
| 203 | + | <tr> | |
| 204 | + | <td class="ln none"> | |
| 205 | + | </td> | |
| 206 | + | <td class="m none"> | |
| 207 | + | </td> | |
| 208 | + | <td class="tx none"> | |
| 209 | + | </td> | |
| 210 | + | <td class="ln add">6</td> | |
| 211 | + | <td class="m add">+</td> | |
| 212 | + | <td class="tx add">six</td> | |
| 213 | + | </tr> | |
| 214 | + | </tbody> | |
| 215 | + | </table> | |
| 216 | + | </div> | |
| 217 | + | </details> | |
| 218 | + | <details class="dfile" id="f2" data-file> | |
| 219 | + | <summary> | |
| 220 | + | <span class="st">M</span> | |
| 221 | + | <span class="p">run.sh</span> | |
| 222 | + | <span class="plus">+0</span> | |
| 223 | + | <span class="minus">-0</span> | |
| 224 | + | <span class="note">mode 100644 → 100755</span> | |
| 225 | + | </summary> | |
| 226 | + | <p class="note pad">mode 100644 → 100755</p> | |
| 227 | + | </details> | |
| 228 | + | </div> | |
| 229 | + | </main> | |
| 230 | + | <aside class="rail" aria-label="Context"> | |
| 231 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 232 | + | <summary>2 changed files</summary> | |
| 233 | + | <section aria-labelledby="r-files"> | |
| 234 | + | <h2 id="r-files">files <span>2</span> | |
| 235 | + | </h2> | |
| 236 | + | <ol class="files" aria-label="Changed files"> | |
| 237 | + | <li> | |
| 238 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 239 | + | <span class="st" title="M">M</span> | |
| 240 | + | <span class="p" title="a.txt">a.txt</span> | |
| 241 | + | <span class="n"> | |
| 242 | + | <span class="plus">+2</span> | |
| 243 | + | <span class="minus">-1</span> | |
| 244 | + | </span> | |
| 245 | + | <span class="b" aria-hidden="true"> | |
| 246 | + | <span class="plus">+++++++</span> | |
| 247 | + | <span class="minus">---</span> | |
| 248 | + | </span> | |
| 249 | + | </a> | |
| 250 | + | </li> | |
| 251 | + | <li> | |
| 252 | + | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 253 | + | <span class="st" title="M">M</span> | |
| 254 | + | <span class="p" title="run.sh">run.sh</span> | |
| 255 | + | <span class="n"> | |
| 256 | + | <span class="plus">+0</span> | |
| 257 | + | <span class="minus">-0</span> | |
| 258 | + | </span> | |
| 259 | + | <span class="b" aria-hidden="true"> | |
| 260 | + | <span class="plus"> | |
| 261 | + | </span> | |
| 262 | + | <span class="minus"> | |
| 263 | + | </span>··········</span> | |
| 264 | + | </a> | |
| 265 | + | </li> | |
| 266 | + | </ol> | |
| 267 | + | </section> | |
| 268 | + | </details> | |
| 269 | + | </aside> | |
| 270 | + | </div> | |
| 271 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 272 | + | <div class="panel-head"> | |
| 273 | + | <span>keys</span> | |
| 274 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 275 | + | </div> | |
| 276 | + | <dl class="keys"> | |
| 277 | + | <div> | |
| 278 | + | <dt> | |
| 279 | + | <kbd>j</kbd> | |
| 280 | + | <kbd>k</kbd> | |
| 281 | + | </dt> | |
| 282 | + | <dd>move cursor down / up</dd> | |
| 283 | + | </div> | |
| 284 | + | <div> | |
| 285 | + | <dt> | |
| 286 | + | <kbd>enter</kbd> | |
| 287 | + | </dt> | |
| 288 | + | <dd>open row under cursor</dd> | |
| 289 | + | </div> | |
| 290 | + | <div> | |
| 291 | + | <dt> | |
| 292 | + | <kbd>g</kbd> | |
| 293 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 294 | + | </dt> | |
| 295 | + | <dd>first / last row</dd> | |
| 296 | + | </div> | |
| 297 | + | <div> | |
| 298 | + | <dt> | |
| 299 | + | <kbd>/</kbd> | |
| 300 | + | </dt> | |
| 301 | + | <dd>focus the filter</dd> | |
| 302 | + | </div> | |
| 303 | + | <div> | |
| 304 | + | <dt> | |
| 305 | + | <kbd>g h</kbd> | |
| 306 | + | <kbd>g t</kbd> | |
| 307 | + | </dt> | |
| 308 | + | <dd>go home / tree</dd> | |
| 309 | + | </div> | |
| 310 | + | <div> | |
| 311 | + | <dt> | |
| 312 | + | <kbd>g c</kbd> | |
| 313 | + | <kbd>g r</kbd> | |
| 314 | + | </dt> | |
| 315 | + | <dd>go commits / refs</dd> | |
| 316 | + | </div> | |
| 317 | + | <div> | |
| 318 | + | <dt> | |
| 319 | + | <kbd>b</kbd> | |
| 320 | + | </dt> | |
| 321 | + | <dd>switch ref (branches, tags)</dd> | |
| 322 | + | </div> | |
| 323 | + | <div> | |
| 324 | + | <dt> | |
| 325 | + | <kbd>:</kbd> | |
| 326 | + | <kbd>f</kbd> | |
| 327 | + | </dt> | |
| 328 | + | <dd>go to: section, ref, file, repo</dd> | |
| 329 | + | </div> | |
| 330 | + | <div> | |
| 331 | + | <dt> | |
| 332 | + | <kbd>y</kbd> | |
| 333 | + | </dt> | |
| 334 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 335 | + | </div> | |
| 336 | + | <div> | |
| 337 | + | <dt> | |
| 338 | + | <kbd>s</kbd> | |
| 339 | + | <kbd>t</kbd> | |
| 340 | + | <kbd>c</kbd> | |
| 341 | + | <kbd>r</kbd> | |
| 342 | + | </dt> | |
| 343 | + | <dd>repo sections (when shown)</dd> | |
| 344 | + | </div> | |
| 345 | + | <div> | |
| 346 | + | <dt> | |
| 347 | + | <kbd>d</kbd> | |
| 348 | + | <kbd>u</kbd> | |
| 349 | + | <kbd>x</kbd> | |
| 350 | + | </dt> | |
| 351 | + | <dd>commit: split / unified / collapse all</dd> | |
| 352 | + | </div> | |
| 353 | + | <div> | |
| 354 | + | <dt> | |
| 355 | + | <kbd>esc</kbd> | |
| 356 | + | </dt> | |
| 357 | + | <dd>close panel</dd> | |
| 358 | + | </div> | |
| 359 | + | </dl> | |
| 360 | + | </dialog> | |
| 361 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 362 | + | <div class="panel-head"> | |
| 363 | + | <label for="refs-filter">ref></label> | |
| 364 | + | <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))"> | |
| 365 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 366 | + | </div> | |
| 367 | + | <div class="panel-cols" data-picklist> | |
| 368 | + | <section aria-label="Branches"> | |
| 369 | + | <h2>branches <span class="mute">2</span> | |
| 370 | + | </h2> | |
| 371 | + | <ul> | |
| 372 | + | <li> | |
| 373 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 374 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 375 | + | </li> | |
| 376 | + | <li> | |
| 377 | + | <a href="/diffs/tree/side"> | |
| 378 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 379 | + | </li> | |
| 380 | + | </ul> | |
| 381 | + | </section> | |
| 382 | + | <section aria-label="Tags"> | |
| 383 | + | <h2>tags <span class="mute">1</span> | |
| 384 | + | </h2> | |
| 385 | + | <ul> | |
| 386 | + | <li> | |
| 387 | + | <a href="/diffs/tree/v1.0.0"> | |
| 388 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 389 | + | </li> | |
| 390 | + | </ul> | |
| 391 | + | </section> | |
| 392 | + | </div> | |
| 393 | + | </dialog> | |
| 394 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 395 | + | <div class="panel-head"> | |
| 396 | + | <label for="cmd-q" class="hi">:</label> | |
| 397 | + | <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(); | |
| 398 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 399 | + | 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(); | |
| 400 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 401 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 402 | + | 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' })) } | |
| 403 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 404 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 405 | + | </div> | |
| 406 | + | <ul id="cmd-list" class="cmd-list"> | |
| 407 | + | <li data-k="go home" data-cursor> | |
| 408 | + | <a href="/" tabindex="-1"> | |
| 409 | + | <span class="k">go</span>home</a> | |
| 410 | + | </li> | |
| 411 | + | <li data-k="section summary"> | |
| 412 | + | <a href="/diffs" tabindex="-1"> | |
| 413 | + | <span class="k">section</span>summary</a> | |
| 414 | + | </li> | |
| 415 | + | <li data-k="section tree"> | |
| 416 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 417 | + | <span class="k">section</span>tree</a> | |
| 418 | + | </li> | |
| 419 | + | <li data-k="section commits"> | |
| 420 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 421 | + | <span class="k">section</span>commits</a> | |
| 422 | + | </li> | |
| 423 | + | <li data-k="section refs"> | |
| 424 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 425 | + | <span class="k">section</span>refs</a> | |
| 426 | + | </li> | |
| 427 | + | <li data-k="branch main"> | |
| 428 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 429 | + | <span class="k">branch</span>main</a> | |
| 430 | + | </li> | |
| 431 | + | <li data-k="branch side"> | |
| 432 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 433 | + | <span class="k">branch</span>side</a> | |
| 434 | + | </li> | |
| 435 | + | <li data-k="tag v1.0.0"> | |
| 436 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 437 | + | <span class="k">tag</span>v1.0.0</a> | |
| 438 | + | </li> | |
| 439 | + | </ul> | |
| 440 | + | </dialog> | |
| 441 | + | <footer class="bar status" aria-label="Status"> | |
| 442 | + | <span class="mode">commit</span> | |
| 443 | + | <span class="st-ctx">SHA7 · 2 files · +2 -1</span> | |
| 444 | + | <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'))"> | |
| 445 | + | <span class="hi">:</span> go to…</button> | |
| 446 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 447 | + | </span> | |
| 448 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 449 | + | <span class="st-src"> | |
| 450 | + | <span class="st-ver">ermite devel · </span> | |
| 451 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 452 | + | </span> | |
| 453 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 454 | + | </footer> | |
| 455 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 456 | + | if (q('dialog[open]').count) return; | |
| 457 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 458 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 459 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 460 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 461 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 462 | + | })(1); | |
| 463 | + | k-k from:body -> ((d) => { | |
| 464 | + | if (q('dialog[open]').count) return; | |
| 465 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 466 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 467 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 468 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 469 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 470 | + | })(-1); | |
| 471 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 472 | + | 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)); | |
| 473 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 474 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 475 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 476 | + | 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()); | |
| 477 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 478 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 479 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 480 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 481 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 482 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 483 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 484 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 485 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 486 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 487 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 488 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 489 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 490 | + | k-permalink from:body -> { | |
| 491 | + | let b = document.body.dataset; | |
| 492 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 493 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 494 | + | navigator.clipboard?.writeText(location.href); | |
| 495 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 496 | + | }"> | |
| 497 | + | </div> | |
| 498 | + | </body> | |
| 499 | + | </html> | |
A cmd/ermite/testdata/web/commit-rename.html +406 -0
| @@ -0,0 +1,406 @@ | |||
| 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>SHA7 rename a file and delete another · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>rename a file and delete another</h1> | |
| 60 | + | <dl class="kv show-kv"> | |
| 61 | + | <dt>commit</dt> | |
| 62 | + | <dd>SHA40</dd> | |
| 63 | + | <dt>author</dt> | |
| 64 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 65 | + | <dt>parent</dt> | |
| 66 | + | <dd> | |
| 67 | + | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 68 | + | </dd> | |
| 69 | + | </dl> | |
| 70 | + | </header> | |
| 71 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 72 | + | <span class="dim">2 files · <span class="plus">+0</span> | |
| 73 | + | <span class="minus">-1</span> | |
| 74 | + | </span> | |
| 75 | + | <span class="grow"> | |
| 76 | + | </span> | |
| 77 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 78 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 79 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 80 | + | </div> | |
| 81 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 82 | + | <details class="dfile" id="f1" data-file> | |
| 83 | + | <summary> | |
| 84 | + | <span class="st">R</span> | |
| 85 | + | <span class="p">old.txt → docs-old.txt</span> | |
| 86 | + | <span class="plus">+0</span> | |
| 87 | + | <span class="minus">-0</span> | |
| 88 | + | <span class="note">renamed, 100% similar</span> | |
| 89 | + | </summary> | |
| 90 | + | <p class="note pad">renamed, 100% similar</p> | |
| 91 | + | </details> | |
| 92 | + | <details class="dfile" id="f2" data-file open> | |
| 93 | + | <summary> | |
| 94 | + | <span class="st">D</span> | |
| 95 | + | <span class="p">gone.txt</span> | |
| 96 | + | <span class="plus">+0</span> | |
| 97 | + | <span class="minus">-1</span> | |
| 98 | + | </summary> | |
| 99 | + | <div class="dscroll"> | |
| 100 | + | <table class="dt uni" aria-label="gone.txt, unified"> | |
| 101 | + | <tbody> | |
| 102 | + | <tr class="hunk"> | |
| 103 | + | <td colspan="4">@@ -1,1 +0,0 @@</td> | |
| 104 | + | </tr> | |
| 105 | + | <tr class="del"> | |
| 106 | + | <td class="ln">1</td> | |
| 107 | + | <td class="ln"> | |
| 108 | + | </td> | |
| 109 | + | <td class="m">-</td> | |
| 110 | + | <td class="tx">bye</td> | |
| 111 | + | </tr> | |
| 112 | + | </tbody> | |
| 113 | + | </table> | |
| 114 | + | <table class="dt spl" aria-label="gone.txt, split"> | |
| 115 | + | <tbody> | |
| 116 | + | <tr class="hunk"> | |
| 117 | + | <td colspan="6">@@ -1,1 +0,0 @@</td> | |
| 118 | + | </tr> | |
| 119 | + | <tr> | |
| 120 | + | <td class="ln del">1</td> | |
| 121 | + | <td class="m del">-</td> | |
| 122 | + | <td class="tx del">bye</td> | |
| 123 | + | <td class="ln none"> | |
| 124 | + | </td> | |
| 125 | + | <td class="m none"> | |
| 126 | + | </td> | |
| 127 | + | <td class="tx none"> | |
| 128 | + | </td> | |
| 129 | + | </tr> | |
| 130 | + | </tbody> | |
| 131 | + | </table> | |
| 132 | + | </div> | |
| 133 | + | </details> | |
| 134 | + | </div> | |
| 135 | + | </main> | |
| 136 | + | <aside class="rail" aria-label="Context"> | |
| 137 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 138 | + | <summary>2 changed files</summary> | |
| 139 | + | <section aria-labelledby="r-files"> | |
| 140 | + | <h2 id="r-files">files <span>2</span> | |
| 141 | + | </h2> | |
| 142 | + | <ol class="files" aria-label="Changed files"> | |
| 143 | + | <li> | |
| 144 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 145 | + | <span class="st" title="R">R</span> | |
| 146 | + | <span class="p" title="old.txt → docs-old.txt">old.txt → docs-old.txt</span> | |
| 147 | + | <span class="n"> | |
| 148 | + | <span class="plus">+0</span> | |
| 149 | + | <span class="minus">-0</span> | |
| 150 | + | </span> | |
| 151 | + | <span class="b" aria-hidden="true"> | |
| 152 | + | <span class="plus"> | |
| 153 | + | </span> | |
| 154 | + | <span class="minus"> | |
| 155 | + | </span>··········</span> | |
| 156 | + | </a> | |
| 157 | + | </li> | |
| 158 | + | <li> | |
| 159 | + | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 160 | + | <span class="st" title="D">D</span> | |
| 161 | + | <span class="p" title="gone.txt">gone.txt</span> | |
| 162 | + | <span class="n"> | |
| 163 | + | <span class="plus">+0</span> | |
| 164 | + | <span class="minus">-1</span> | |
| 165 | + | </span> | |
| 166 | + | <span class="b" aria-hidden="true"> | |
| 167 | + | <span class="plus"> | |
| 168 | + | </span> | |
| 169 | + | <span class="minus">----------</span> | |
| 170 | + | </span> | |
| 171 | + | </a> | |
| 172 | + | </li> | |
| 173 | + | </ol> | |
| 174 | + | </section> | |
| 175 | + | </details> | |
| 176 | + | </aside> | |
| 177 | + | </div> | |
| 178 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 179 | + | <div class="panel-head"> | |
| 180 | + | <span>keys</span> | |
| 181 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 182 | + | </div> | |
| 183 | + | <dl class="keys"> | |
| 184 | + | <div> | |
| 185 | + | <dt> | |
| 186 | + | <kbd>j</kbd> | |
| 187 | + | <kbd>k</kbd> | |
| 188 | + | </dt> | |
| 189 | + | <dd>move cursor down / up</dd> | |
| 190 | + | </div> | |
| 191 | + | <div> | |
| 192 | + | <dt> | |
| 193 | + | <kbd>enter</kbd> | |
| 194 | + | </dt> | |
| 195 | + | <dd>open row under cursor</dd> | |
| 196 | + | </div> | |
| 197 | + | <div> | |
| 198 | + | <dt> | |
| 199 | + | <kbd>g</kbd> | |
| 200 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 201 | + | </dt> | |
| 202 | + | <dd>first / last row</dd> | |
| 203 | + | </div> | |
| 204 | + | <div> | |
| 205 | + | <dt> | |
| 206 | + | <kbd>/</kbd> | |
| 207 | + | </dt> | |
| 208 | + | <dd>focus the filter</dd> | |
| 209 | + | </div> | |
| 210 | + | <div> | |
| 211 | + | <dt> | |
| 212 | + | <kbd>g h</kbd> | |
| 213 | + | <kbd>g t</kbd> | |
| 214 | + | </dt> | |
| 215 | + | <dd>go home / tree</dd> | |
| 216 | + | </div> | |
| 217 | + | <div> | |
| 218 | + | <dt> | |
| 219 | + | <kbd>g c</kbd> | |
| 220 | + | <kbd>g r</kbd> | |
| 221 | + | </dt> | |
| 222 | + | <dd>go commits / refs</dd> | |
| 223 | + | </div> | |
| 224 | + | <div> | |
| 225 | + | <dt> | |
| 226 | + | <kbd>b</kbd> | |
| 227 | + | </dt> | |
| 228 | + | <dd>switch ref (branches, tags)</dd> | |
| 229 | + | </div> | |
| 230 | + | <div> | |
| 231 | + | <dt> | |
| 232 | + | <kbd>:</kbd> | |
| 233 | + | <kbd>f</kbd> | |
| 234 | + | </dt> | |
| 235 | + | <dd>go to: section, ref, file, repo</dd> | |
| 236 | + | </div> | |
| 237 | + | <div> | |
| 238 | + | <dt> | |
| 239 | + | <kbd>y</kbd> | |
| 240 | + | </dt> | |
| 241 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 242 | + | </div> | |
| 243 | + | <div> | |
| 244 | + | <dt> | |
| 245 | + | <kbd>s</kbd> | |
| 246 | + | <kbd>t</kbd> | |
| 247 | + | <kbd>c</kbd> | |
| 248 | + | <kbd>r</kbd> | |
| 249 | + | </dt> | |
| 250 | + | <dd>repo sections (when shown)</dd> | |
| 251 | + | </div> | |
| 252 | + | <div> | |
| 253 | + | <dt> | |
| 254 | + | <kbd>d</kbd> | |
| 255 | + | <kbd>u</kbd> | |
| 256 | + | <kbd>x</kbd> | |
| 257 | + | </dt> | |
| 258 | + | <dd>commit: split / unified / collapse all</dd> | |
| 259 | + | </div> | |
| 260 | + | <div> | |
| 261 | + | <dt> | |
| 262 | + | <kbd>esc</kbd> | |
| 263 | + | </dt> | |
| 264 | + | <dd>close panel</dd> | |
| 265 | + | </div> | |
| 266 | + | </dl> | |
| 267 | + | </dialog> | |
| 268 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 269 | + | <div class="panel-head"> | |
| 270 | + | <label for="refs-filter">ref></label> | |
| 271 | + | <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))"> | |
| 272 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 273 | + | </div> | |
| 274 | + | <div class="panel-cols" data-picklist> | |
| 275 | + | <section aria-label="Branches"> | |
| 276 | + | <h2>branches <span class="mute">2</span> | |
| 277 | + | </h2> | |
| 278 | + | <ul> | |
| 279 | + | <li> | |
| 280 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 281 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 282 | + | </li> | |
| 283 | + | <li> | |
| 284 | + | <a href="/diffs/tree/side"> | |
| 285 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 286 | + | </li> | |
| 287 | + | </ul> | |
| 288 | + | </section> | |
| 289 | + | <section aria-label="Tags"> | |
| 290 | + | <h2>tags <span class="mute">1</span> | |
| 291 | + | </h2> | |
| 292 | + | <ul> | |
| 293 | + | <li> | |
| 294 | + | <a href="/diffs/tree/v1.0.0"> | |
| 295 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 296 | + | </li> | |
| 297 | + | </ul> | |
| 298 | + | </section> | |
| 299 | + | </div> | |
| 300 | + | </dialog> | |
| 301 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 302 | + | <div class="panel-head"> | |
| 303 | + | <label for="cmd-q" class="hi">:</label> | |
| 304 | + | <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(); | |
| 305 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 306 | + | 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(); | |
| 307 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 308 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 309 | + | 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' })) } | |
| 310 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 311 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 312 | + | </div> | |
| 313 | + | <ul id="cmd-list" class="cmd-list"> | |
| 314 | + | <li data-k="go home" data-cursor> | |
| 315 | + | <a href="/" tabindex="-1"> | |
| 316 | + | <span class="k">go</span>home</a> | |
| 317 | + | </li> | |
| 318 | + | <li data-k="section summary"> | |
| 319 | + | <a href="/diffs" tabindex="-1"> | |
| 320 | + | <span class="k">section</span>summary</a> | |
| 321 | + | </li> | |
| 322 | + | <li data-k="section tree"> | |
| 323 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 324 | + | <span class="k">section</span>tree</a> | |
| 325 | + | </li> | |
| 326 | + | <li data-k="section commits"> | |
| 327 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 328 | + | <span class="k">section</span>commits</a> | |
| 329 | + | </li> | |
| 330 | + | <li data-k="section refs"> | |
| 331 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 332 | + | <span class="k">section</span>refs</a> | |
| 333 | + | </li> | |
| 334 | + | <li data-k="branch main"> | |
| 335 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 336 | + | <span class="k">branch</span>main</a> | |
| 337 | + | </li> | |
| 338 | + | <li data-k="branch side"> | |
| 339 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 340 | + | <span class="k">branch</span>side</a> | |
| 341 | + | </li> | |
| 342 | + | <li data-k="tag v1.0.0"> | |
| 343 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 344 | + | <span class="k">tag</span>v1.0.0</a> | |
| 345 | + | </li> | |
| 346 | + | </ul> | |
| 347 | + | </dialog> | |
| 348 | + | <footer class="bar status" aria-label="Status"> | |
| 349 | + | <span class="mode">commit</span> | |
| 350 | + | <span class="st-ctx">SHA7 · 2 files · +0 -1</span> | |
| 351 | + | <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'))"> | |
| 352 | + | <span class="hi">:</span> go to…</button> | |
| 353 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 354 | + | </span> | |
| 355 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 356 | + | <span class="st-src"> | |
| 357 | + | <span class="st-ver">ermite devel · </span> | |
| 358 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 359 | + | </span> | |
| 360 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 361 | + | </footer> | |
| 362 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 363 | + | if (q('dialog[open]').count) return; | |
| 364 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 365 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 366 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 367 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 368 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 369 | + | })(1); | |
| 370 | + | k-k from:body -> ((d) => { | |
| 371 | + | if (q('dialog[open]').count) return; | |
| 372 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 373 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 374 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 375 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 376 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 377 | + | })(-1); | |
| 378 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 379 | + | 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)); | |
| 380 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 381 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 382 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 383 | + | 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()); | |
| 384 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 385 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 386 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 387 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 388 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 389 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 390 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 391 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 392 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 393 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 394 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 395 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 396 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 397 | + | k-permalink from:body -> { | |
| 398 | + | let b = document.body.dataset; | |
| 399 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 400 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 401 | + | navigator.clipboard?.writeText(location.href); | |
| 402 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 403 | + | }"> | |
| 404 | + | </div> | |
| 405 | + | </body> | |
| 406 | + | </html> | |
A cmd/ermite/testdata/web/commit-root.html +698 -0
| @@ -0,0 +1,698 @@ | |||
| 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>SHA7 root: the first files · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>root: the first files</h1> | |
| 60 | + | <dl class="kv show-kv"> | |
| 61 | + | <dt>commit</dt> | |
| 62 | + | <dd>SHA40</dd> | |
| 63 | + | <dt>author</dt> | |
| 64 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 65 | + | <dt>parent</dt> | |
| 66 | + | <dd>none (root commit) </dd> | |
| 67 | + | </dl> | |
| 68 | + | </header> | |
| 69 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 70 | + | <span class="dim">5 files · <span class="plus">+11</span> | |
| 71 | + | <span class="minus">-0</span> | |
| 72 | + | </span> | |
| 73 | + | <span class="grow"> | |
| 74 | + | </span> | |
| 75 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 76 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 77 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 78 | + | </div> | |
| 79 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 80 | + | <details class="dfile" id="f1" data-file open> | |
| 81 | + | <summary> | |
| 82 | + | <span class="st">A</span> | |
| 83 | + | <span class="p">a.txt</span> | |
| 84 | + | <span class="plus">+5</span> | |
| 85 | + | <span class="minus">-0</span> | |
| 86 | + | </summary> | |
| 87 | + | <div class="dscroll"> | |
| 88 | + | <table class="dt uni" aria-label="a.txt, unified"> | |
| 89 | + | <tbody> | |
| 90 | + | <tr class="hunk"> | |
| 91 | + | <td colspan="4">@@ -0,0 +1,5 @@</td> | |
| 92 | + | </tr> | |
| 93 | + | <tr class="add"> | |
| 94 | + | <td class="ln"> | |
| 95 | + | </td> | |
| 96 | + | <td class="ln">1</td> | |
| 97 | + | <td class="m">+</td> | |
| 98 | + | <td class="tx">one</td> | |
| 99 | + | </tr> | |
| 100 | + | <tr class="add"> | |
| 101 | + | <td class="ln"> | |
| 102 | + | </td> | |
| 103 | + | <td class="ln">2</td> | |
| 104 | + | <td class="m">+</td> | |
| 105 | + | <td class="tx">two</td> | |
| 106 | + | </tr> | |
| 107 | + | <tr class="add"> | |
| 108 | + | <td class="ln"> | |
| 109 | + | </td> | |
| 110 | + | <td class="ln">3</td> | |
| 111 | + | <td class="m">+</td> | |
| 112 | + | <td class="tx">three</td> | |
| 113 | + | </tr> | |
| 114 | + | <tr class="add"> | |
| 115 | + | <td class="ln"> | |
| 116 | + | </td> | |
| 117 | + | <td class="ln">4</td> | |
| 118 | + | <td class="m">+</td> | |
| 119 | + | <td class="tx">four</td> | |
| 120 | + | </tr> | |
| 121 | + | <tr class="add"> | |
| 122 | + | <td class="ln"> | |
| 123 | + | </td> | |
| 124 | + | <td class="ln">5</td> | |
| 125 | + | <td class="m">+</td> | |
| 126 | + | <td class="tx">five</td> | |
| 127 | + | </tr> | |
| 128 | + | </tbody> | |
| 129 | + | </table> | |
| 130 | + | <table class="dt spl" aria-label="a.txt, split"> | |
| 131 | + | <tbody> | |
| 132 | + | <tr class="hunk"> | |
| 133 | + | <td colspan="6">@@ -0,0 +1,5 @@</td> | |
| 134 | + | </tr> | |
| 135 | + | <tr> | |
| 136 | + | <td class="ln none"> | |
| 137 | + | </td> | |
| 138 | + | <td class="m none"> | |
| 139 | + | </td> | |
| 140 | + | <td class="tx none"> | |
| 141 | + | </td> | |
| 142 | + | <td class="ln add">1</td> | |
| 143 | + | <td class="m add">+</td> | |
| 144 | + | <td class="tx add">one</td> | |
| 145 | + | </tr> | |
| 146 | + | <tr> | |
| 147 | + | <td class="ln none"> | |
| 148 | + | </td> | |
| 149 | + | <td class="m none"> | |
| 150 | + | </td> | |
| 151 | + | <td class="tx none"> | |
| 152 | + | </td> | |
| 153 | + | <td class="ln add">2</td> | |
| 154 | + | <td class="m add">+</td> | |
| 155 | + | <td class="tx add">two</td> | |
| 156 | + | </tr> | |
| 157 | + | <tr> | |
| 158 | + | <td class="ln none"> | |
| 159 | + | </td> | |
| 160 | + | <td class="m none"> | |
| 161 | + | </td> | |
| 162 | + | <td class="tx none"> | |
| 163 | + | </td> | |
| 164 | + | <td class="ln add">3</td> | |
| 165 | + | <td class="m add">+</td> | |
| 166 | + | <td class="tx add">three</td> | |
| 167 | + | </tr> | |
| 168 | + | <tr> | |
| 169 | + | <td class="ln none"> | |
| 170 | + | </td> | |
| 171 | + | <td class="m none"> | |
| 172 | + | </td> | |
| 173 | + | <td class="tx none"> | |
| 174 | + | </td> | |
| 175 | + | <td class="ln add">4</td> | |
| 176 | + | <td class="m add">+</td> | |
| 177 | + | <td class="tx add">four</td> | |
| 178 | + | </tr> | |
| 179 | + | <tr> | |
| 180 | + | <td class="ln none"> | |
| 181 | + | </td> | |
| 182 | + | <td class="m none"> | |
| 183 | + | </td> | |
| 184 | + | <td class="tx none"> | |
| 185 | + | </td> | |
| 186 | + | <td class="ln add">5</td> | |
| 187 | + | <td class="m add">+</td> | |
| 188 | + | <td class="tx add">five</td> | |
| 189 | + | </tr> | |
| 190 | + | </tbody> | |
| 191 | + | </table> | |
| 192 | + | </div> | |
| 193 | + | </details> | |
| 194 | + | <details class="dfile" id="f2" data-file open> | |
| 195 | + | <summary> | |
| 196 | + | <span class="st">A</span> | |
| 197 | + | <span class="p">gone.txt</span> | |
| 198 | + | <span class="plus">+1</span> | |
| 199 | + | <span class="minus">-0</span> | |
| 200 | + | </summary> | |
| 201 | + | <div class="dscroll"> | |
| 202 | + | <table class="dt uni" aria-label="gone.txt, unified"> | |
| 203 | + | <tbody> | |
| 204 | + | <tr class="hunk"> | |
| 205 | + | <td colspan="4">@@ -0,0 +1,1 @@</td> | |
| 206 | + | </tr> | |
| 207 | + | <tr class="add"> | |
| 208 | + | <td class="ln"> | |
| 209 | + | </td> | |
| 210 | + | <td class="ln">1</td> | |
| 211 | + | <td class="m">+</td> | |
| 212 | + | <td class="tx">bye</td> | |
| 213 | + | </tr> | |
| 214 | + | </tbody> | |
| 215 | + | </table> | |
| 216 | + | <table class="dt spl" aria-label="gone.txt, split"> | |
| 217 | + | <tbody> | |
| 218 | + | <tr class="hunk"> | |
| 219 | + | <td colspan="6">@@ -0,0 +1,1 @@</td> | |
| 220 | + | </tr> | |
| 221 | + | <tr> | |
| 222 | + | <td class="ln none"> | |
| 223 | + | </td> | |
| 224 | + | <td class="m none"> | |
| 225 | + | </td> | |
| 226 | + | <td class="tx none"> | |
| 227 | + | </td> | |
| 228 | + | <td class="ln add">1</td> | |
| 229 | + | <td class="m add">+</td> | |
| 230 | + | <td class="tx add">bye</td> | |
| 231 | + | </tr> | |
| 232 | + | </tbody> | |
| 233 | + | </table> | |
| 234 | + | </div> | |
| 235 | + | </details> | |
| 236 | + | <details class="dfile" id="f3" data-file> | |
| 237 | + | <summary> | |
| 238 | + | <span class="st">A</span> | |
| 239 | + | <span class="p">logo.bin</span> | |
| 240 | + | <span class="plus">+0</span> | |
| 241 | + | <span class="minus">-0</span> | |
| 242 | + | <span class="note">binary file: no text diff</span> | |
| 243 | + | </summary> | |
| 244 | + | <p class="note pad">binary file: no text diff</p> | |
| 245 | + | </details> | |
| 246 | + | <details class="dfile" id="f4" data-file open> | |
| 247 | + | <summary> | |
| 248 | + | <span class="st">A</span> | |
| 249 | + | <span class="p">old.txt</span> | |
| 250 | + | <span class="plus">+3</span> | |
| 251 | + | <span class="minus">-0</span> | |
| 252 | + | </summary> | |
| 253 | + | <div class="dscroll"> | |
| 254 | + | <table class="dt uni" aria-label="old.txt, unified"> | |
| 255 | + | <tbody> | |
| 256 | + | <tr class="hunk"> | |
| 257 | + | <td colspan="4">@@ -0,0 +1,3 @@</td> | |
| 258 | + | </tr> | |
| 259 | + | <tr class="add"> | |
| 260 | + | <td class="ln"> | |
| 261 | + | </td> | |
| 262 | + | <td class="ln">1</td> | |
| 263 | + | <td class="m">+</td> | |
| 264 | + | <td class="tx">keep me</td> | |
| 265 | + | </tr> | |
| 266 | + | <tr class="add"> | |
| 267 | + | <td class="ln"> | |
| 268 | + | </td> | |
| 269 | + | <td class="ln">2</td> | |
| 270 | + | <td class="m">+</td> | |
| 271 | + | <td class="tx">keep me too</td> | |
| 272 | + | </tr> | |
| 273 | + | <tr class="add"> | |
| 274 | + | <td class="ln"> | |
| 275 | + | </td> | |
| 276 | + | <td class="ln">3</td> | |
| 277 | + | <td class="m">+</td> | |
| 278 | + | <td class="tx">and me</td> | |
| 279 | + | </tr> | |
| 280 | + | </tbody> | |
| 281 | + | </table> | |
| 282 | + | <table class="dt spl" aria-label="old.txt, split"> | |
| 283 | + | <tbody> | |
| 284 | + | <tr class="hunk"> | |
| 285 | + | <td colspan="6">@@ -0,0 +1,3 @@</td> | |
| 286 | + | </tr> | |
| 287 | + | <tr> | |
| 288 | + | <td class="ln none"> | |
| 289 | + | </td> | |
| 290 | + | <td class="m none"> | |
| 291 | + | </td> | |
| 292 | + | <td class="tx none"> | |
| 293 | + | </td> | |
| 294 | + | <td class="ln add">1</td> | |
| 295 | + | <td class="m add">+</td> | |
| 296 | + | <td class="tx add">keep me</td> | |
| 297 | + | </tr> | |
| 298 | + | <tr> | |
| 299 | + | <td class="ln none"> | |
| 300 | + | </td> | |
| 301 | + | <td class="m none"> | |
| 302 | + | </td> | |
| 303 | + | <td class="tx none"> | |
| 304 | + | </td> | |
| 305 | + | <td class="ln add">2</td> | |
| 306 | + | <td class="m add">+</td> | |
| 307 | + | <td class="tx add">keep me too</td> | |
| 308 | + | </tr> | |
| 309 | + | <tr> | |
| 310 | + | <td class="ln none"> | |
| 311 | + | </td> | |
| 312 | + | <td class="m none"> | |
| 313 | + | </td> | |
| 314 | + | <td class="tx none"> | |
| 315 | + | </td> | |
| 316 | + | <td class="ln add">3</td> | |
| 317 | + | <td class="m add">+</td> | |
| 318 | + | <td class="tx add">and me</td> | |
| 319 | + | </tr> | |
| 320 | + | </tbody> | |
| 321 | + | </table> | |
| 322 | + | </div> | |
| 323 | + | </details> | |
| 324 | + | <details class="dfile" id="f5" data-file open> | |
| 325 | + | <summary> | |
| 326 | + | <span class="st">A</span> | |
| 327 | + | <span class="p">run.sh</span> | |
| 328 | + | <span class="plus">+2</span> | |
| 329 | + | <span class="minus">-0</span> | |
| 330 | + | </summary> | |
| 331 | + | <div class="dscroll"> | |
| 332 | + | <table class="dt uni" aria-label="run.sh, unified"> | |
| 333 | + | <tbody> | |
| 334 | + | <tr class="hunk"> | |
| 335 | + | <td colspan="4">@@ -0,0 +1,2 @@</td> | |
| 336 | + | </tr> | |
| 337 | + | <tr class="add"> | |
| 338 | + | <td class="ln"> | |
| 339 | + | </td> | |
| 340 | + | <td class="ln">1</td> | |
| 341 | + | <td class="m">+</td> | |
| 342 | + | <td class="tx">#!/bin/sh</td> | |
| 343 | + | </tr> | |
| 344 | + | <tr class="add"> | |
| 345 | + | <td class="ln"> | |
| 346 | + | </td> | |
| 347 | + | <td class="ln">2</td> | |
| 348 | + | <td class="m">+</td> | |
| 349 | + | <td class="tx">echo hi</td> | |
| 350 | + | </tr> | |
| 351 | + | </tbody> | |
| 352 | + | </table> | |
| 353 | + | <table class="dt spl" aria-label="run.sh, split"> | |
| 354 | + | <tbody> | |
| 355 | + | <tr class="hunk"> | |
| 356 | + | <td colspan="6">@@ -0,0 +1,2 @@</td> | |
| 357 | + | </tr> | |
| 358 | + | <tr> | |
| 359 | + | <td class="ln none"> | |
| 360 | + | </td> | |
| 361 | + | <td class="m none"> | |
| 362 | + | </td> | |
| 363 | + | <td class="tx none"> | |
| 364 | + | </td> | |
| 365 | + | <td class="ln add">1</td> | |
| 366 | + | <td class="m add">+</td> | |
| 367 | + | <td class="tx add">#!/bin/sh</td> | |
| 368 | + | </tr> | |
| 369 | + | <tr> | |
| 370 | + | <td class="ln none"> | |
| 371 | + | </td> | |
| 372 | + | <td class="m none"> | |
| 373 | + | </td> | |
| 374 | + | <td class="tx none"> | |
| 375 | + | </td> | |
| 376 | + | <td class="ln add">2</td> | |
| 377 | + | <td class="m add">+</td> | |
| 378 | + | <td class="tx add">echo hi</td> | |
| 379 | + | </tr> | |
| 380 | + | </tbody> | |
| 381 | + | </table> | |
| 382 | + | </div> | |
| 383 | + | </details> | |
| 384 | + | </div> | |
| 385 | + | </main> | |
| 386 | + | <aside class="rail" aria-label="Context"> | |
| 387 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 388 | + | <summary>5 changed files</summary> | |
| 389 | + | <section aria-labelledby="r-files"> | |
| 390 | + | <h2 id="r-files">files <span>5</span> | |
| 391 | + | </h2> | |
| 392 | + | <ol class="files" aria-label="Changed files"> | |
| 393 | + | <li> | |
| 394 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 395 | + | <span class="st" title="A">A</span> | |
| 396 | + | <span class="p" title="a.txt">a.txt</span> | |
| 397 | + | <span class="n"> | |
| 398 | + | <span class="plus">+5</span> | |
| 399 | + | <span class="minus">-0</span> | |
| 400 | + | </span> | |
| 401 | + | <span class="b" aria-hidden="true"> | |
| 402 | + | <span class="plus">++++++++++</span> | |
| 403 | + | <span class="minus"> | |
| 404 | + | </span> | |
| 405 | + | </span> | |
| 406 | + | </a> | |
| 407 | + | </li> | |
| 408 | + | <li> | |
| 409 | + | <a href="#f2" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f2' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 410 | + | <span class="st" title="A">A</span> | |
| 411 | + | <span class="p" title="gone.txt">gone.txt</span> | |
| 412 | + | <span class="n"> | |
| 413 | + | <span class="plus">+1</span> | |
| 414 | + | <span class="minus">-0</span> | |
| 415 | + | </span> | |
| 416 | + | <span class="b" aria-hidden="true"> | |
| 417 | + | <span class="plus">++</span> | |
| 418 | + | <span class="minus"> | |
| 419 | + | </span>········</span> | |
| 420 | + | </a> | |
| 421 | + | </li> | |
| 422 | + | <li> | |
| 423 | + | <a href="#f3" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f3' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 424 | + | <span class="st" title="A">A</span> | |
| 425 | + | <span class="p" title="logo.bin">logo.bin</span> | |
| 426 | + | <span class="n"> | |
| 427 | + | <span class="plus">+0</span> | |
| 428 | + | <span class="minus">-0</span> | |
| 429 | + | </span> | |
| 430 | + | <span class="b" aria-hidden="true"> | |
| 431 | + | <span class="plus"> | |
| 432 | + | </span> | |
| 433 | + | <span class="minus"> | |
| 434 | + | </span>bin</span> | |
| 435 | + | </a> | |
| 436 | + | </li> | |
| 437 | + | <li> | |
| 438 | + | <a href="#f4" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f4' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 439 | + | <span class="st" title="A">A</span> | |
| 440 | + | <span class="p" title="old.txt">old.txt</span> | |
| 441 | + | <span class="n"> | |
| 442 | + | <span class="plus">+3</span> | |
| 443 | + | <span class="minus">-0</span> | |
| 444 | + | </span> | |
| 445 | + | <span class="b" aria-hidden="true"> | |
| 446 | + | <span class="plus">++++++</span> | |
| 447 | + | <span class="minus"> | |
| 448 | + | </span>····</span> | |
| 449 | + | </a> | |
| 450 | + | </li> | |
| 451 | + | <li> | |
| 452 | + | <a href="#f5" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f5' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 453 | + | <span class="st" title="A">A</span> | |
| 454 | + | <span class="p" title="run.sh">run.sh</span> | |
| 455 | + | <span class="n"> | |
| 456 | + | <span class="plus">+2</span> | |
| 457 | + | <span class="minus">-0</span> | |
| 458 | + | </span> | |
| 459 | + | <span class="b" aria-hidden="true"> | |
| 460 | + | <span class="plus">++++</span> | |
| 461 | + | <span class="minus"> | |
| 462 | + | </span>······</span> | |
| 463 | + | </a> | |
| 464 | + | </li> | |
| 465 | + | </ol> | |
| 466 | + | </section> | |
| 467 | + | </details> | |
| 468 | + | </aside> | |
| 469 | + | </div> | |
| 470 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 471 | + | <div class="panel-head"> | |
| 472 | + | <span>keys</span> | |
| 473 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 474 | + | </div> | |
| 475 | + | <dl class="keys"> | |
| 476 | + | <div> | |
| 477 | + | <dt> | |
| 478 | + | <kbd>j</kbd> | |
| 479 | + | <kbd>k</kbd> | |
| 480 | + | </dt> | |
| 481 | + | <dd>move cursor down / up</dd> | |
| 482 | + | </div> | |
| 483 | + | <div> | |
| 484 | + | <dt> | |
| 485 | + | <kbd>enter</kbd> | |
| 486 | + | </dt> | |
| 487 | + | <dd>open row under cursor</dd> | |
| 488 | + | </div> | |
| 489 | + | <div> | |
| 490 | + | <dt> | |
| 491 | + | <kbd>g</kbd> | |
| 492 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 493 | + | </dt> | |
| 494 | + | <dd>first / last row</dd> | |
| 495 | + | </div> | |
| 496 | + | <div> | |
| 497 | + | <dt> | |
| 498 | + | <kbd>/</kbd> | |
| 499 | + | </dt> | |
| 500 | + | <dd>focus the filter</dd> | |
| 501 | + | </div> | |
| 502 | + | <div> | |
| 503 | + | <dt> | |
| 504 | + | <kbd>g h</kbd> | |
| 505 | + | <kbd>g t</kbd> | |
| 506 | + | </dt> | |
| 507 | + | <dd>go home / tree</dd> | |
| 508 | + | </div> | |
| 509 | + | <div> | |
| 510 | + | <dt> | |
| 511 | + | <kbd>g c</kbd> | |
| 512 | + | <kbd>g r</kbd> | |
| 513 | + | </dt> | |
| 514 | + | <dd>go commits / refs</dd> | |
| 515 | + | </div> | |
| 516 | + | <div> | |
| 517 | + | <dt> | |
| 518 | + | <kbd>b</kbd> | |
| 519 | + | </dt> | |
| 520 | + | <dd>switch ref (branches, tags)</dd> | |
| 521 | + | </div> | |
| 522 | + | <div> | |
| 523 | + | <dt> | |
| 524 | + | <kbd>:</kbd> | |
| 525 | + | <kbd>f</kbd> | |
| 526 | + | </dt> | |
| 527 | + | <dd>go to: section, ref, file, repo</dd> | |
| 528 | + | </div> | |
| 529 | + | <div> | |
| 530 | + | <dt> | |
| 531 | + | <kbd>y</kbd> | |
| 532 | + | </dt> | |
| 533 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 534 | + | </div> | |
| 535 | + | <div> | |
| 536 | + | <dt> | |
| 537 | + | <kbd>s</kbd> | |
| 538 | + | <kbd>t</kbd> | |
| 539 | + | <kbd>c</kbd> | |
| 540 | + | <kbd>r</kbd> | |
| 541 | + | </dt> | |
| 542 | + | <dd>repo sections (when shown)</dd> | |
| 543 | + | </div> | |
| 544 | + | <div> | |
| 545 | + | <dt> | |
| 546 | + | <kbd>d</kbd> | |
| 547 | + | <kbd>u</kbd> | |
| 548 | + | <kbd>x</kbd> | |
| 549 | + | </dt> | |
| 550 | + | <dd>commit: split / unified / collapse all</dd> | |
| 551 | + | </div> | |
| 552 | + | <div> | |
| 553 | + | <dt> | |
| 554 | + | <kbd>esc</kbd> | |
| 555 | + | </dt> | |
| 556 | + | <dd>close panel</dd> | |
| 557 | + | </div> | |
| 558 | + | </dl> | |
| 559 | + | </dialog> | |
| 560 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 561 | + | <div class="panel-head"> | |
| 562 | + | <label for="refs-filter">ref></label> | |
| 563 | + | <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))"> | |
| 564 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 565 | + | </div> | |
| 566 | + | <div class="panel-cols" data-picklist> | |
| 567 | + | <section aria-label="Branches"> | |
| 568 | + | <h2>branches <span class="mute">2</span> | |
| 569 | + | </h2> | |
| 570 | + | <ul> | |
| 571 | + | <li> | |
| 572 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 573 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 574 | + | </li> | |
| 575 | + | <li> | |
| 576 | + | <a href="/diffs/tree/side"> | |
| 577 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 578 | + | </li> | |
| 579 | + | </ul> | |
| 580 | + | </section> | |
| 581 | + | <section aria-label="Tags"> | |
| 582 | + | <h2>tags <span class="mute">1</span> | |
| 583 | + | </h2> | |
| 584 | + | <ul> | |
| 585 | + | <li> | |
| 586 | + | <a href="/diffs/tree/v1.0.0"> | |
| 587 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 588 | + | </li> | |
| 589 | + | </ul> | |
| 590 | + | </section> | |
| 591 | + | </div> | |
| 592 | + | </dialog> | |
| 593 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 594 | + | <div class="panel-head"> | |
| 595 | + | <label for="cmd-q" class="hi">:</label> | |
| 596 | + | <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(); | |
| 597 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 598 | + | 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(); | |
| 599 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 600 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 601 | + | 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' })) } | |
| 602 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 603 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 604 | + | </div> | |
| 605 | + | <ul id="cmd-list" class="cmd-list"> | |
| 606 | + | <li data-k="go home" data-cursor> | |
| 607 | + | <a href="/" tabindex="-1"> | |
| 608 | + | <span class="k">go</span>home</a> | |
| 609 | + | </li> | |
| 610 | + | <li data-k="section summary"> | |
| 611 | + | <a href="/diffs" tabindex="-1"> | |
| 612 | + | <span class="k">section</span>summary</a> | |
| 613 | + | </li> | |
| 614 | + | <li data-k="section tree"> | |
| 615 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 616 | + | <span class="k">section</span>tree</a> | |
| 617 | + | </li> | |
| 618 | + | <li data-k="section commits"> | |
| 619 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 620 | + | <span class="k">section</span>commits</a> | |
| 621 | + | </li> | |
| 622 | + | <li data-k="section refs"> | |
| 623 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 624 | + | <span class="k">section</span>refs</a> | |
| 625 | + | </li> | |
| 626 | + | <li data-k="branch main"> | |
| 627 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 628 | + | <span class="k">branch</span>main</a> | |
| 629 | + | </li> | |
| 630 | + | <li data-k="branch side"> | |
| 631 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 632 | + | <span class="k">branch</span>side</a> | |
| 633 | + | </li> | |
| 634 | + | <li data-k="tag v1.0.0"> | |
| 635 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 636 | + | <span class="k">tag</span>v1.0.0</a> | |
| 637 | + | </li> | |
| 638 | + | </ul> | |
| 639 | + | </dialog> | |
| 640 | + | <footer class="bar status" aria-label="Status"> | |
| 641 | + | <span class="mode">commit</span> | |
| 642 | + | <span class="st-ctx">SHA7 · 5 files · +11 -0</span> | |
| 643 | + | <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'))"> | |
| 644 | + | <span class="hi">:</span> go to…</button> | |
| 645 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 646 | + | </span> | |
| 647 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 648 | + | <span class="st-src"> | |
| 649 | + | <span class="st-ver">ermite devel · </span> | |
| 650 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 651 | + | </span> | |
| 652 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 653 | + | </footer> | |
| 654 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 655 | + | if (q('dialog[open]').count) return; | |
| 656 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 657 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 658 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 659 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 660 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 661 | + | })(1); | |
| 662 | + | k-k from:body -> ((d) => { | |
| 663 | + | if (q('dialog[open]').count) return; | |
| 664 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 665 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 666 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 667 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 668 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 669 | + | })(-1); | |
| 670 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 671 | + | 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)); | |
| 672 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 673 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 674 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 675 | + | 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()); | |
| 676 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 677 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 678 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 679 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 680 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 681 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 682 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 683 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 684 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 685 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 686 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 687 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 688 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 689 | + | k-permalink from:body -> { | |
| 690 | + | let b = document.body.dataset; | |
| 691 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 692 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 693 | + | navigator.clipboard?.writeText(location.href); | |
| 694 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 695 | + | }"> | |
| 696 | + | </div> | |
| 697 | + | </body> | |
| 698 | + | </html> | |
A cmd/ermite/testdata/web/commit-trojan.html +515 -0
| @@ -0,0 +1,515 @@ | |||
| 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>SHA7 add evil.go · diffs · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/diffs">diffs</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <a href="/diffs/commits/main">commit</a> | |
| 31 | + | </li> | |
| 32 | + | <li> | |
| 33 | + | <span aria-current="page">SHA7</span> | |
| 34 | + | </li> | |
| 35 | + | </ol> | |
| 36 | + | </nav> | |
| 37 | + | <nav class="secs" aria-label="Sections"> | |
| 38 | + | <ul> | |
| 39 | + | <li> | |
| 40 | + | <a href="/diffs" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/diffs/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/diffs/commits/main" data-key="c" aria-keyshortcuts="g c" aria-current="page">commits</a> | |
| 47 | + | </li> | |
| 48 | + | <li> | |
| 49 | + | <a href="/diffs/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 50 | + | </li> | |
| 51 | + | </ul> | |
| 52 | + | </nav> | |
| 53 | + | <button type="button" class="ref" aria-haspopup="dialog" aria-controls="panel-refs" aria-keyshortcuts="b" title="switch ref (b)" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-refs'))">main<span class="sha">@SHA7</span> | |
| 54 | + | </button> | |
| 55 | + | </header> | |
| 56 | + | <div class="work has-rail"> | |
| 57 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 58 | + | <header class="show"> | |
| 59 | + | <h1>add evil.go</h1> | |
| 60 | + | <pre class="msg">looks <U+202E>fine<U+202C></pre> | |
| 61 | + | <dl class="kv show-kv"> | |
| 62 | + | <dt>commit</dt> | |
| 63 | + | <dd>SHA40</dd> | |
| 64 | + | <dt>author</dt> | |
| 65 | + | <dd>T · <time datetime="STAMP" title="STAMP">AGO</time> · STAMP</dd> | |
| 66 | + | <dt>parent</dt> | |
| 67 | + | <dd> | |
| 68 | + | <a href="/diffs/commit/SHA40">SHA7</a> | |
| 69 | + | </dd> | |
| 70 | + | </dl> | |
| 71 | + | </header> | |
| 72 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 73 | + | <span class="dim">1 file · <span class="plus">+8</span> | |
| 74 | + | <span class="minus">-0</span> | |
| 75 | + | </span> | |
| 76 | + | <span class="grow"> | |
| 77 | + | </span> | |
| 78 | + | <a href="/diffs/commit/SHA40?view=unified" class="vbtn" data-view="unified" aria-keyshortcuts="u" aria-current="true" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'unified' ? 'true' : false">[u]nified</a> | |
| 79 | + | <a href="/diffs/commit/SHA40?view=split" class="vbtn" data-view="split" aria-keyshortcuts="d" hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" :aria-current="q('#diffs').attr['data-diff'] === 'split' ? 'true' : false">[d] split</a> | |
| 80 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 81 | + | </div> | |
| 82 | + | <p class="notice">this diff holds invisible characters (direction controls, zero-width characters). They are shown as <span class="inv"><U+XXXX></span> markers, never applied.</p> | |
| 83 | + | <div id="diffs" data-diff="unified" hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff"> | |
| 84 | + | <details class="dfile" id="f1" data-file open> | |
| 85 | + | <summary> | |
| 86 | + | <span class="st">A</span> | |
| 87 | + | <span class="p">evil.go</span> | |
| 88 | + | <span class="plus">+8</span> | |
| 89 | + | <span class="minus">-0</span> | |
| 90 | + | </summary> | |
| 91 | + | <div class="dscroll"> | |
| 92 | + | <table class="dt uni" aria-label="evil.go, unified"> | |
| 93 | + | <tbody> | |
| 94 | + | <tr class="hunk"> | |
| 95 | + | <td colspan="4">@@ -0,0 +1,8 @@</td> | |
| 96 | + | </tr> | |
| 97 | + | <tr class="add"> | |
| 98 | + | <td class="ln"> | |
| 99 | + | </td> | |
| 100 | + | <td class="ln">1</td> | |
| 101 | + | <td class="m">+</td> | |
| 102 | + | <td class="tx">package evil</td> | |
| 103 | + | </tr> | |
| 104 | + | <tr class="add"> | |
| 105 | + | <td class="ln"> | |
| 106 | + | </td> | |
| 107 | + | <td class="ln">2</td> | |
| 108 | + | <td class="m">+</td> | |
| 109 | + | <td class="tx"> | |
| 110 | + | </td> | |
| 111 | + | </tr> | |
| 112 | + | <tr class="add"> | |
| 113 | + | <td class="ln"> | |
| 114 | + | </td> | |
| 115 | + | <td class="ln">3</td> | |
| 116 | + | <td class="m">+</td> | |
| 117 | + | <td class="tx">func admin(user string) bool {</td> | |
| 118 | + | </tr> | |
| 119 | + | <tr class="add"> | |
| 120 | + | <td class="ln"> | |
| 121 | + | </td> | |
| 122 | + | <td class="ln">4</td> | |
| 123 | + | <td class="m">+</td> | |
| 124 | + | <td class="tx"> if user == "user<span class="inv" title="invisible character"><U+202E></span> | |
| 125 | + | <span class="inv" title="invisible character"><U+2066></span>// check<span class="inv" title="invisible character"><U+2069></span> | |
| 126 | + | <span class="inv" title="invisible character"><U+2066></span>" {</td> | |
| 127 | + | </tr> | |
| 128 | + | <tr class="add"> | |
| 129 | + | <td class="ln"> | |
| 130 | + | </td> | |
| 131 | + | <td class="ln">5</td> | |
| 132 | + | <td class="m">+</td> | |
| 133 | + | <td class="tx"> return true // z<span class="inv" title="invisible character"><U+200B></span>w</td> | |
| 134 | + | </tr> | |
| 135 | + | <tr class="add"> | |
| 136 | + | <td class="ln"> | |
| 137 | + | </td> | |
| 138 | + | <td class="ln">6</td> | |
| 139 | + | <td class="m">+</td> | |
| 140 | + | <td class="tx"> }</td> | |
| 141 | + | </tr> | |
| 142 | + | <tr class="add"> | |
| 143 | + | <td class="ln"> | |
| 144 | + | </td> | |
| 145 | + | <td class="ln">7</td> | |
| 146 | + | <td class="m">+</td> | |
| 147 | + | <td class="tx"> return false</td> | |
| 148 | + | </tr> | |
| 149 | + | <tr class="add"> | |
| 150 | + | <td class="ln"> | |
| 151 | + | </td> | |
| 152 | + | <td class="ln">8</td> | |
| 153 | + | <td class="m">+</td> | |
| 154 | + | <td class="tx">}</td> | |
| 155 | + | </tr> | |
| 156 | + | </tbody> | |
| 157 | + | </table> | |
| 158 | + | <table class="dt spl" aria-label="evil.go, split"> | |
| 159 | + | <tbody> | |
| 160 | + | <tr class="hunk"> | |
| 161 | + | <td colspan="6">@@ -0,0 +1,8 @@</td> | |
| 162 | + | </tr> | |
| 163 | + | <tr> | |
| 164 | + | <td class="ln none"> | |
| 165 | + | </td> | |
| 166 | + | <td class="m none"> | |
| 167 | + | </td> | |
| 168 | + | <td class="tx none"> | |
| 169 | + | </td> | |
| 170 | + | <td class="ln add">1</td> | |
| 171 | + | <td class="m add">+</td> | |
| 172 | + | <td class="tx add">package evil</td> | |
| 173 | + | </tr> | |
| 174 | + | <tr> | |
| 175 | + | <td class="ln none"> | |
| 176 | + | </td> | |
| 177 | + | <td class="m none"> | |
| 178 | + | </td> | |
| 179 | + | <td class="tx none"> | |
| 180 | + | </td> | |
| 181 | + | <td class="ln add">2</td> | |
| 182 | + | <td class="m add">+</td> | |
| 183 | + | <td class="tx add"> | |
| 184 | + | </td> | |
| 185 | + | </tr> | |
| 186 | + | <tr> | |
| 187 | + | <td class="ln none"> | |
| 188 | + | </td> | |
| 189 | + | <td class="m none"> | |
| 190 | + | </td> | |
| 191 | + | <td class="tx none"> | |
| 192 | + | </td> | |
| 193 | + | <td class="ln add">3</td> | |
| 194 | + | <td class="m add">+</td> | |
| 195 | + | <td class="tx add">func admin(user string) bool {</td> | |
| 196 | + | </tr> | |
| 197 | + | <tr> | |
| 198 | + | <td class="ln none"> | |
| 199 | + | </td> | |
| 200 | + | <td class="m none"> | |
| 201 | + | </td> | |
| 202 | + | <td class="tx none"> | |
| 203 | + | </td> | |
| 204 | + | <td class="ln add">4</td> | |
| 205 | + | <td class="m add">+</td> | |
| 206 | + | <td class="tx add"> if user == "user<span class="inv" title="invisible character"><U+202E></span> | |
| 207 | + | <span class="inv" title="invisible character"><U+2066></span>// check<span class="inv" title="invisible character"><U+2069></span> | |
| 208 | + | <span class="inv" title="invisible character"><U+2066></span>" {</td> | |
| 209 | + | </tr> | |
| 210 | + | <tr> | |
| 211 | + | <td class="ln none"> | |
| 212 | + | </td> | |
| 213 | + | <td class="m none"> | |
| 214 | + | </td> | |
| 215 | + | <td class="tx none"> | |
| 216 | + | </td> | |
| 217 | + | <td class="ln add">5</td> | |
| 218 | + | <td class="m add">+</td> | |
| 219 | + | <td class="tx add"> return true // z<span class="inv" title="invisible character"><U+200B></span>w</td> | |
| 220 | + | </tr> | |
| 221 | + | <tr> | |
| 222 | + | <td class="ln none"> | |
| 223 | + | </td> | |
| 224 | + | <td class="m none"> | |
| 225 | + | </td> | |
| 226 | + | <td class="tx none"> | |
| 227 | + | </td> | |
| 228 | + | <td class="ln add">6</td> | |
| 229 | + | <td class="m add">+</td> | |
| 230 | + | <td class="tx add"> }</td> | |
| 231 | + | </tr> | |
| 232 | + | <tr> | |
| 233 | + | <td class="ln none"> | |
| 234 | + | </td> | |
| 235 | + | <td class="m none"> | |
| 236 | + | </td> | |
| 237 | + | <td class="tx none"> | |
| 238 | + | </td> | |
| 239 | + | <td class="ln add">7</td> | |
| 240 | + | <td class="m add">+</td> | |
| 241 | + | <td class="tx add"> return false</td> | |
| 242 | + | </tr> | |
| 243 | + | <tr> | |
| 244 | + | <td class="ln none"> | |
| 245 | + | </td> | |
| 246 | + | <td class="m none"> | |
| 247 | + | </td> | |
| 248 | + | <td class="tx none"> | |
| 249 | + | </td> | |
| 250 | + | <td class="ln add">8</td> | |
| 251 | + | <td class="m add">+</td> | |
| 252 | + | <td class="tx add">}</td> | |
| 253 | + | </tr> | |
| 254 | + | </tbody> | |
| 255 | + | </table> | |
| 256 | + | </div> | |
| 257 | + | </details> | |
| 258 | + | </div> | |
| 259 | + | </main> | |
| 260 | + | <aside class="rail" aria-label="Context"> | |
| 261 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 262 | + | <summary>1 changed file</summary> | |
| 263 | + | <section aria-labelledby="r-files"> | |
| 264 | + | <h2 id="r-files">files <span>1</span> | |
| 265 | + | </h2> | |
| 266 | + | <ol class="files" aria-label="Changed files"> | |
| 267 | + | <li> | |
| 268 | + | <a href="#f1" :aria-current="(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === 'f1' ? 'true' : false })()" hx-on:click="document.getElementById(this.hash.slice(1)).open = true"> | |
| 269 | + | <span class="st" title="A">A</span> | |
| 270 | + | <span class="p" title="evil.go">evil.go</span> | |
| 271 | + | <span class="n"> | |
| 272 | + | <span class="plus">+8</span> | |
| 273 | + | <span class="minus">-0</span> | |
| 274 | + | </span> | |
| 275 | + | <span class="b" aria-hidden="true"> | |
| 276 | + | <span class="plus">++++++++++</span> | |
| 277 | + | <span class="minus"> | |
| 278 | + | </span> | |
| 279 | + | </span> | |
| 280 | + | </a> | |
| 281 | + | </li> | |
| 282 | + | </ol> | |
| 283 | + | </section> | |
| 284 | + | </details> | |
| 285 | + | </aside> | |
| 286 | + | </div> | |
| 287 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 288 | + | <div class="panel-head"> | |
| 289 | + | <span>keys</span> | |
| 290 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 291 | + | </div> | |
| 292 | + | <dl class="keys"> | |
| 293 | + | <div> | |
| 294 | + | <dt> | |
| 295 | + | <kbd>j</kbd> | |
| 296 | + | <kbd>k</kbd> | |
| 297 | + | </dt> | |
| 298 | + | <dd>move cursor down / up</dd> | |
| 299 | + | </div> | |
| 300 | + | <div> | |
| 301 | + | <dt> | |
| 302 | + | <kbd>enter</kbd> | |
| 303 | + | </dt> | |
| 304 | + | <dd>open row under cursor</dd> | |
| 305 | + | </div> | |
| 306 | + | <div> | |
| 307 | + | <dt> | |
| 308 | + | <kbd>g</kbd> | |
| 309 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 310 | + | </dt> | |
| 311 | + | <dd>first / last row</dd> | |
| 312 | + | </div> | |
| 313 | + | <div> | |
| 314 | + | <dt> | |
| 315 | + | <kbd>/</kbd> | |
| 316 | + | </dt> | |
| 317 | + | <dd>focus the filter</dd> | |
| 318 | + | </div> | |
| 319 | + | <div> | |
| 320 | + | <dt> | |
| 321 | + | <kbd>g h</kbd> | |
| 322 | + | <kbd>g t</kbd> | |
| 323 | + | </dt> | |
| 324 | + | <dd>go home / tree</dd> | |
| 325 | + | </div> | |
| 326 | + | <div> | |
| 327 | + | <dt> | |
| 328 | + | <kbd>g c</kbd> | |
| 329 | + | <kbd>g r</kbd> | |
| 330 | + | </dt> | |
| 331 | + | <dd>go commits / refs</dd> | |
| 332 | + | </div> | |
| 333 | + | <div> | |
| 334 | + | <dt> | |
| 335 | + | <kbd>b</kbd> | |
| 336 | + | </dt> | |
| 337 | + | <dd>switch ref (branches, tags)</dd> | |
| 338 | + | </div> | |
| 339 | + | <div> | |
| 340 | + | <dt> | |
| 341 | + | <kbd>:</kbd> | |
| 342 | + | <kbd>f</kbd> | |
| 343 | + | </dt> | |
| 344 | + | <dd>go to: section, ref, file, repo</dd> | |
| 345 | + | </div> | |
| 346 | + | <div> | |
| 347 | + | <dt> | |
| 348 | + | <kbd>y</kbd> | |
| 349 | + | </dt> | |
| 350 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 351 | + | </div> | |
| 352 | + | <div> | |
| 353 | + | <dt> | |
| 354 | + | <kbd>s</kbd> | |
| 355 | + | <kbd>t</kbd> | |
| 356 | + | <kbd>c</kbd> | |
| 357 | + | <kbd>r</kbd> | |
| 358 | + | </dt> | |
| 359 | + | <dd>repo sections (when shown)</dd> | |
| 360 | + | </div> | |
| 361 | + | <div> | |
| 362 | + | <dt> | |
| 363 | + | <kbd>d</kbd> | |
| 364 | + | <kbd>u</kbd> | |
| 365 | + | <kbd>x</kbd> | |
| 366 | + | </dt> | |
| 367 | + | <dd>commit: split / unified / collapse all</dd> | |
| 368 | + | </div> | |
| 369 | + | <div> | |
| 370 | + | <dt> | |
| 371 | + | <kbd>esc</kbd> | |
| 372 | + | </dt> | |
| 373 | + | <dd>close panel</dd> | |
| 374 | + | </div> | |
| 375 | + | </dl> | |
| 376 | + | </dialog> | |
| 377 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 378 | + | <div class="panel-head"> | |
| 379 | + | <label for="refs-filter">ref></label> | |
| 380 | + | <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))"> | |
| 381 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 382 | + | </div> | |
| 383 | + | <div class="panel-cols" data-picklist> | |
| 384 | + | <section aria-label="Branches"> | |
| 385 | + | <h2>branches <span class="mute">2</span> | |
| 386 | + | </h2> | |
| 387 | + | <ul> | |
| 388 | + | <li> | |
| 389 | + | <a href="/diffs/tree/main" aria-current="true"> | |
| 390 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 391 | + | </li> | |
| 392 | + | <li> | |
| 393 | + | <a href="/diffs/tree/side"> | |
| 394 | + | <span class="mark" aria-hidden="true"> </span>side</a> | |
| 395 | + | </li> | |
| 396 | + | </ul> | |
| 397 | + | </section> | |
| 398 | + | <section aria-label="Tags"> | |
| 399 | + | <h2>tags <span class="mute">1</span> | |
| 400 | + | </h2> | |
| 401 | + | <ul> | |
| 402 | + | <li> | |
| 403 | + | <a href="/diffs/tree/v1.0.0"> | |
| 404 | + | <span class="mark" aria-hidden="true"> </span>v1.0.0</a> | |
| 405 | + | </li> | |
| 406 | + | </ul> | |
| 407 | + | </section> | |
| 408 | + | </div> | |
| 409 | + | </dialog> | |
| 410 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 411 | + | <div class="panel-head"> | |
| 412 | + | <label for="cmd-q" class="hi">:</label> | |
| 413 | + | <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(); | |
| 414 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 415 | + | 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(); | |
| 416 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 417 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 418 | + | 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' })) } | |
| 419 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 420 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 421 | + | </div> | |
| 422 | + | <ul id="cmd-list" class="cmd-list"> | |
| 423 | + | <li data-k="go home" data-cursor> | |
| 424 | + | <a href="/" tabindex="-1"> | |
| 425 | + | <span class="k">go</span>home</a> | |
| 426 | + | </li> | |
| 427 | + | <li data-k="section summary"> | |
| 428 | + | <a href="/diffs" tabindex="-1"> | |
| 429 | + | <span class="k">section</span>summary</a> | |
| 430 | + | </li> | |
| 431 | + | <li data-k="section tree"> | |
| 432 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 433 | + | <span class="k">section</span>tree</a> | |
| 434 | + | </li> | |
| 435 | + | <li data-k="section commits"> | |
| 436 | + | <a href="/diffs/commits/main" tabindex="-1"> | |
| 437 | + | <span class="k">section</span>commits</a> | |
| 438 | + | </li> | |
| 439 | + | <li data-k="section refs"> | |
| 440 | + | <a href="/diffs/refs" tabindex="-1"> | |
| 441 | + | <span class="k">section</span>refs</a> | |
| 442 | + | </li> | |
| 443 | + | <li data-k="branch main"> | |
| 444 | + | <a href="/diffs/tree/main" tabindex="-1"> | |
| 445 | + | <span class="k">branch</span>main</a> | |
| 446 | + | </li> | |
| 447 | + | <li data-k="branch side"> | |
| 448 | + | <a href="/diffs/tree/side" tabindex="-1"> | |
| 449 | + | <span class="k">branch</span>side</a> | |
| 450 | + | </li> | |
| 451 | + | <li data-k="tag v1.0.0"> | |
| 452 | + | <a href="/diffs/tree/v1.0.0" tabindex="-1"> | |
| 453 | + | <span class="k">tag</span>v1.0.0</a> | |
| 454 | + | </li> | |
| 455 | + | </ul> | |
| 456 | + | </dialog> | |
| 457 | + | <footer class="bar status" aria-label="Status"> | |
| 458 | + | <span class="mode">commit</span> | |
| 459 | + | <span class="st-ctx">SHA7 · 1 file · +8 -0</span> | |
| 460 | + | <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'))"> | |
| 461 | + | <span class="hi">:</span> go to…</button> | |
| 462 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 463 | + | </span> | |
| 464 | + | <span class="st-hints">d split · u unified · x collapse all · ? help</span> | |
| 465 | + | <span class="st-src"> | |
| 466 | + | <span class="st-ver">ermite devel · </span> | |
| 467 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 468 | + | </span> | |
| 469 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 470 | + | </footer> | |
| 471 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 472 | + | if (q('dialog[open]').count) return; | |
| 473 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 474 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 475 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 476 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 477 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 478 | + | })(1); | |
| 479 | + | k-k from:body -> ((d) => { | |
| 480 | + | if (q('dialog[open]').count) return; | |
| 481 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 482 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 483 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 484 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 485 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 486 | + | })(-1); | |
| 487 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 488 | + | 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)); | |
| 489 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 490 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 491 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 492 | + | 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()); | |
| 493 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 494 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 495 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 496 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 497 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 498 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 499 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 500 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 501 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 502 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 503 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 504 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 505 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 506 | + | k-permalink from:body -> { | |
| 507 | + | let b = document.body.dataset; | |
| 508 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 509 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 510 | + | navigator.clipboard?.writeText(location.href); | |
| 511 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 512 | + | }"> | |
| 513 | + | </div> | |
| 514 | + | </body> | |
| 515 | + | </html> | |
A cmd/ermite/web_commit_test.go +157 -0
| @@ -0,0 +1,157 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // AC1: the commit pages render for the fixtures with the structure of the | |
| 16 | + | // mockup (golden HTML, normalised). | |
| 17 | + | func TestWebCommitGolden(t *testing.T) { | |
| 18 | + | h := newHarness(t, harnessOpts{}) | |
| 19 | + | f := h.diffRepo("diffs") | |
| 20 | + | ||
| 21 | + | for _, tt := range []struct{ name, path string }{ | |
| 22 | + | {"commit-modify", "/diffs/commit/" + f.modify}, | |
| 23 | + | {"commit-modify-split", "/diffs/commit/" + f.modify + "?view=split"}, | |
| 24 | + | {"commit-root", "/diffs/commit/" + f.root}, | |
| 25 | + | {"commit-rename", "/diffs/commit/" + f.rename}, | |
| 26 | + | {"commit-binary", "/diffs/commit/" + f.binary}, | |
| 27 | + | {"commit-merge", "/diffs/commit/" + f.merge}, | |
| 28 | + | {"commit-trojan", "/diffs/commit/" + f.trojan}, | |
| 29 | + | {"commit-big", "/diffs/commit/" + f.big}, | |
| 30 | + | } { | |
| 31 | + | t.Run(tt.name, func(t *testing.T) { | |
| 32 | + | status, hdr, body := h.get(tt.path) | |
| 33 | + | require.Equal(t, 200, status) | |
| 34 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |
| 35 | + | assertNoInline(t, body) | |
| 36 | + | golden(t, tt.name, normalise(body)) | |
| 37 | + | }) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // The behaviours of the commit pages, asserted on their own. | |
| 42 | + | func TestWebCommit(t *testing.T) { | |
| 43 | + | h := newHarness(t, harnessOpts{}) | |
| 44 | + | f := h.diffRepo("diffs") | |
| 45 | + | get := func(path string, status int) string { | |
| 46 | + | t.Helper() | |
| 47 | + | st, _, body := h.get(path) | |
| 48 | + | require.Equal(t, status, st, path) | |
| 49 | + | return body | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | t.Run("commit", func(t *testing.T) { | |
| 53 | + | body := get("/diffs/commit/"+f.modify, 200) | |
| 54 | + | assert.Contains(t, body, "<h1>modify a file and make a script executable</h1>") | |
| 55 | + | assert.Contains(t, body, "The body has a second paragraph.") | |
| 56 | + | assert.Contains(t, body, "And <b>html</b> &", "the message is text") | |
| 57 | + | assert.NotContains(t, body, "<b>html</b>") | |
| 58 | + | assert.Contains(t, body, "mode 100644 → 100755") | |
| 59 | + | assert.Contains(t, body, `<td class="m">+</td>`, "added lines keep their marker") | |
| 60 | + | assert.Contains(t, body, `<td class="m">-</td>`, "deleted lines keep their marker") | |
| 61 | + | assert.Contains(t, body, `data-diff="unified"`) | |
| 62 | + | assert.Contains(t, body, `hx-live="this.dataset.diff = sessionStorage`, "the browser remembers the layout when the URL does not choose") | |
| 63 | + | assert.Contains(t, body, `<table class="dt uni"`) | |
| 64 | + | assert.Contains(t, body, `<table class="dt spl"`, "both layouts are sent, the toggle needs no request") | |
| 65 | + | assert.Contains(t, body, `href="/diffs/commit/`+f.modify+`?view=split"`, "the toggle works without scripts") | |
| 66 | + | // The abbreviated id opens the same commit. | |
| 67 | + | assert.Equal(t, normalise(body), normalise(get("/diffs/commit/"+f.modify[:12], 200))) | |
| 68 | + | }) | |
| 69 | + | ||
| 70 | + | t.Run("split without scripts", func(t *testing.T) { | |
| 71 | + | body := get("/diffs/commit/"+f.modify+"?view=split", 200) | |
| 72 | + | assert.Contains(t, body, `data-diff="split"`) | |
| 73 | + | assert.NotContains(t, body, `hx-live="this.dataset.diff`, "the URL decides, not the browser memory") | |
| 74 | + | assert.Contains(t, body, `class="ln del"`) | |
| 75 | + | assert.Contains(t, body, `class="ln add"`) | |
| 76 | + | }) | |
| 77 | + | ||
| 78 | + | t.Run("root commit", func(t *testing.T) { | |
| 79 | + | body := get("/diffs/commit/"+f.root, 200) | |
| 80 | + | assert.Contains(t, body, "none (root commit)") | |
| 81 | + | assert.NotContains(t, body, `class="m del"`) | |
| 82 | + | }) | |
| 83 | + | ||
| 84 | + | t.Run("rename delete binary", func(t *testing.T) { | |
| 85 | + | body := get("/diffs/commit/"+f.rename, 200) | |
| 86 | + | assert.Contains(t, body, "old.txt → docs-old.txt") | |
| 87 | + | assert.Contains(t, body, "renamed, 100% similar") | |
| 88 | + | assert.Contains(t, body, "gone.txt") | |
| 89 | + | bin := get("/diffs/commit/"+f.binary, 200) | |
| 90 | + | assert.Contains(t, bin, "binary file: no text diff") | |
| 91 | + | assert.NotContains(t, bin, `<table class="dt`) | |
| 92 | + | }) | |
| 93 | + | ||
| 94 | + | t.Run("merge", func(t *testing.T) { | |
| 95 | + | body := get("/diffs/commit/"+f.merge, 200) | |
| 96 | + | assert.Contains(t, body, "merge of") | |
| 97 | + | assert.Contains(t, body, "diff against the first parent") | |
| 98 | + | assert.Equal(t, 2, strings.Count(body, `href="/diffs/commit/`)-strings.Count(body, `href="/diffs/commit/`+f.merge), "two parent links") | |
| 99 | + | }) | |
| 100 | + | ||
| 101 | + | t.Run("trojan source", func(t *testing.T) { | |
| 102 | + | body := get("/diffs/commit/"+f.trojan, 200) | |
| 103 | + | for _, bad := range []string{"\u202e", "\u2066", "\u2069", "\u200b", "\u202c"} { | |
| 104 | + | assert.NotContains(t, body, bad, "no invisible character reaches the page: %U", []rune(bad)) | |
| 105 | + | } | |
| 106 | + | for _, mark := range []string{"<U+202E>", "<U+2066>", "<U+2069>", "<U+200B>", "<U+202C>"} { | |
| 107 | + | assert.Contains(t, body, mark) | |
| 108 | + | } | |
| 109 | + | assert.Contains(t, body, "holds invisible characters") | |
| 110 | + | }) | |
| 111 | + | ||
| 112 | + | t.Run("truncated", func(t *testing.T) { | |
| 113 | + | body := get("/diffs/commit/"+f.big, 200) | |
| 114 | + | assert.Contains(t, body, "diff too large to show (2600 lines changed): collapsed") | |
| 115 | + | assert.Contains(t, body, "this diff is incomplete") | |
| 116 | + | assert.NotRegexp(t, `<details class="dfile" id="f1"[^>]* open`, body, "a truncated file is collapsed") | |
| 117 | + | assert.NotContains(t, body, "line 2000", "no lines of a truncated file") | |
| 118 | + | }) | |
| 119 | + | ||
| 120 | + | t.Run("not found", func(t *testing.T) { | |
| 121 | + | _, _, missing := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567") | |
| 122 | + | for _, p := range []string{ | |
| 123 | + | "/diffs/commit/0123456789abcdef0123456789abcdef01234567", // no such commit | |
| 124 | + | "/diffs/commit/main", "/diffs/commit/abc", "/diffs/commit/ABCDEF0", "/diffs/commit/v1.0.0", // not a commit id | |
| 125 | + | "/nope/commit/" + f.root, "/diffs/commit/" + f.root + "/x", | |
| 126 | + | } { | |
| 127 | + | st, _, body := h.get(p) | |
| 128 | + | assert.Equal(t, 404, st, p) | |
| 129 | + | assert.NotContains(t, body, "<h1>root", p) | |
| 130 | + | } | |
| 131 | + | st, _, body := h.get("/diffs/commit/0123456789abcdef0123456789abcdef01234567") | |
| 132 | + | assert.Equal(t, 404, st) | |
| 133 | + | assert.Equal(t, normalise(missing), normalise(body)) | |
| 134 | + | }) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // AC2: the commit pages of a private repository are the 404 page of a | |
| 138 | + | // repository that does not exist. | |
| 139 | + | func TestWebCommitPrivate(t *testing.T) { | |
| 140 | + | h := newHarness(t, harnessOpts{}) | |
| 141 | + | wt := h.workTree("main", "secret.txt") | |
| 142 | + | h.createRepo("hidden-one", domain.VisibilityPrivate) | |
| 143 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/hidden-one.git"), "main") | |
| 144 | + | sha := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD")) | |
| 145 | + | ||
| 146 | + | _, _, missing := h.get("/never-existed") | |
| 147 | + | for _, p := range []string{ | |
| 148 | + | "/hidden-one/commit/" + sha, "/hidden-one/commit/" + sha + "?view=split", | |
| 149 | + | } { | |
| 150 | + | st, hdr, body := h.get(p) | |
| 151 | + | assert.Equal(t, 404, st, p) | |
| 152 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |
| 153 | + | assert.NotContains(t, body, "hidden-one", p) | |
| 154 | + | assert.NotContains(t, body, "secret.txt", p) | |
| 155 | + | assert.Equal(t, normalise(missing), normalise(body), p) | |
| 156 | + | } | |
| 157 | + | } | |
A internal/adapters/web/commit.go +188 -0
| @@ -0,0 +1,188 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "slices" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "connectrpc.com/connect" | |
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | ||
| 15 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // bothViewsLines is the largest diff, in lines, that is sent in both the | |
| 19 | + | // unified and the split layout so that the toggle needs no round trip. A | |
| 20 | + | // larger one is sent in the layout asked for only, and the toggle is a link. | |
| 21 | + | const bothViewsLines = 4000 | |
| 22 | + | ||
| 23 | + | // Person is an author or a committer with the date of the action. | |
| 24 | + | type Person struct { | |
| 25 | + | Name, When, WhenAt, Abs string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // CommitPage is a commit with its diff. | |
| 29 | + | type CommitPage struct { | |
| 30 | + | SHA, Short, Subject, Body string | |
| 31 | + | Author Person | |
| 32 | + | // Committer is nil when it is the author, at the same time. | |
| 33 | + | Committer *Person | |
| 34 | + | Parents []Link | |
| 35 | + | Merge bool | |
| 36 | + | Root bool | |
| 37 | + | Refs []string | |
| 38 | + | // NFiles, Adds and Dels are the totals of the commit, including the files | |
| 39 | + | // that the diff omits. | |
| 40 | + | NFiles, Adds, Dels int | |
| 41 | + | Files []DiffFile | |
| 42 | + | // Note says the diff is incomplete. | |
| 43 | + | Note string | |
| 44 | + | // View is the layout shown: "unified" or "split". Pinned is true when the | |
| 45 | + | // URL chose it, which then wins over the choice remembered by the browser. | |
| 46 | + | View string | |
| 47 | + | Pinned bool | |
| 48 | + | // Both is true when both layouts are in the page, and the toggle works in | |
| 49 | + | // the browser; otherwise only View is sent. | |
| 50 | + | Both bool | |
| 51 | + | ShowUnified, ShowSplit bool | |
| 52 | + | UnifiedHref, SplitHref string | |
| 53 | + | // Invisible is true when some line holds an invisible character, shown as a | |
| 54 | + | // marker. | |
| 55 | + | Invisible bool | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // commit shows a commit: its message, its totals, the changed files and the | |
| 59 | + | // diff against the first parent. ?view=split selects the split layout. | |
| 60 | + | func (s *Server) commit(ctx context.Context, r *http.Request) (templ.Component, int, error) { | |
| 61 | + | sha := r.PathValue("sha") | |
| 62 | + | if !isCommitID(sha) { | |
| 63 | + | return nil, 0, errNotFound | |
| 64 | + | } | |
| 65 | + | d, err := s.loadRepo(ctx, r.PathValue("repo")) | |
| 66 | + | if err != nil { | |
| 67 | + | return nil, 0, err | |
| 68 | + | } | |
| 69 | + | res, err := s.cfg.Git.GetCommit(ctx, connect.NewRequest(&pb.GetCommitRequest{Repo: d.name(), Revision: sha})) | |
| 70 | + | if err != nil { | |
| 71 | + | return nil, 0, err | |
| 72 | + | } | |
| 73 | + | c := res.Msg.GetCommit() | |
| 74 | + | if c == nil { | |
| 75 | + | return nil, 0, errNotFound | |
| 76 | + | } | |
| 77 | + | stats := res.Msg.GetStats() | |
| 78 | + | now := s.cfg.Now() | |
| 79 | + | ||
| 80 | + | p := CommitPage{ | |
| 81 | + | SHA: c.GetId(), Short: short(c.GetId()), | |
| 82 | + | Subject: visibleText(c.GetSummary()), Body: commitBody(c), | |
| 83 | + | Author: person(c.GetAuthor(), now), | |
| 84 | + | Merge: len(c.GetParentIds()) > 1, | |
| 85 | + | Root: len(c.GetParentIds()) == 0, | |
| 86 | + | NFiles: int(stats.GetFilesChanged()), | |
| 87 | + | Adds: int(stats.GetAdditions()), | |
| 88 | + | Dels: int(stats.GetDeletions()), | |
| 89 | + | Files: diffFiles(res.Msg.GetDiff().GetFiles()), | |
| 90 | + | View: "unified", | |
| 91 | + | } | |
| 92 | + | if cm := person(c.GetCommitter(), now); cm != p.Author || c.GetCommitter().GetEmail() != c.GetAuthor().GetEmail() { | |
| 93 | + | p.Committer = &cm | |
| 94 | + | } | |
| 95 | + | for _, id := range c.GetParentIds() { | |
| 96 | + | p.Parents = append(p.Parents, Link{Label: short(id), Href: repoHref(d.name(), "commit", id)}) | |
| 97 | + | } | |
| 98 | + | for _, b := range d.branches { | |
| 99 | + | if b.GetCommitId() == c.GetId() { | |
| 100 | + | p.Refs = append(p.Refs, b.GetShortName()) | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | for _, t := range d.tags { | |
| 104 | + | if t.GetCommitId() == c.GetId() { | |
| 105 | + | p.Refs = append(p.Refs, "tag: "+t.GetShortName()) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | p.Note = diffNote(res.Msg.GetDiff(), len(p.Files), p.NFiles) | |
| 109 | + | ||
| 110 | + | lines := 0 | |
| 111 | + | for _, f := range p.Files { | |
| 112 | + | for _, h := range f.Hunks { | |
| 113 | + | lines += len(h.Lines) | |
| 114 | + | p.Invisible = p.Invisible || slices.ContainsFunc(h.Lines, hasMarker) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | p.Both = lines <= bothViewsLines | |
| 118 | + | p.ShowUnified, p.ShowSplit = p.Both || p.View == "unified", p.Both || p.View == "split" | |
| 119 | + | switch r.URL.Query().Get("view") { | |
| 120 | + | case "split": | |
| 121 | + | p.View, p.Pinned = "split", true | |
| 122 | + | case "unified": | |
| 123 | + | p.Pinned = true | |
| 124 | + | } | |
| 125 | + | base := repoHref(d.name(), "commit", c.GetId()) | |
| 126 | + | p.UnifiedHref, p.SplitHref = base+"?view=unified", base+"?view=split" | |
| 127 | + | ||
| 128 | + | l := s.repoLayout(ctx, d, "commits") | |
| 129 | + | l.Title = p.Short + " " + p.Subject + " · " + d.name() + " · " + s.instance | |
| 130 | + | l.SHA, l.RefPath = c.GetId(), "" | |
| 131 | + | history := repoHref(d.name()) | |
| 132 | + | if d.head != nil { | |
| 133 | + | history = repoHref(d.name(), "commits", d.head.GetShortName()) | |
| 134 | + | } | |
| 135 | + | l.Crumbs = []Crumb{ | |
| 136 | + | {Label: d.name(), Href: repoHref(d.name())}, | |
| 137 | + | {Label: "commit", Href: history}, | |
| 138 | + | {Label: p.Short, Current: true}, | |
| 139 | + | } | |
| 140 | + | l.Status = Status{ | |
| 141 | + | Mode: "commit", Context: p.Short + " · " + p.totals(), | |
| 142 | + | Hints: "d split · u unified · x collapse all · ? help", | |
| 143 | + | } | |
| 144 | + | l.HasRail, l.RailSum = true, plural(len(p.Files), "changed file", "changed files") | |
| 145 | + | return Commit(l, p), http.StatusOK, nil | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // totals is "3 files · +12 -4". | |
| 149 | + | func (p CommitPage) totals() string { | |
| 150 | + | return plural(p.NFiles, "file", "files") + " · +" + itoa(p.Adds) + " -" + itoa(p.Dels) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // person converts a signature. | |
| 154 | + | func person(s *pb.Signature, now time.Time) Person { | |
| 155 | + | p := Person{Name: visibleText(s.GetName())} | |
| 156 | + | if t := s.GetTime(); t.IsValid() { | |
| 157 | + | p.When, p.WhenAt, p.Abs = ago(now, t.AsTime()), rfc3339(t.AsTime()), absolute(t.AsTime()) | |
| 158 | + | } | |
| 159 | + | return p | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // commitBody is the message without its first line, for display. | |
| 163 | + | func commitBody(c *pb.Commit) string { | |
| 164 | + | msg := strings.ReplaceAll(c.GetMessage(), "\r\n", "\n") | |
| 165 | + | _, body, _ := strings.Cut(msg, "\n") | |
| 166 | + | return messageText(strings.TrimSpace(body)) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | // diffNote says what is missing from an incomplete diff, "" for a complete one. | |
| 170 | + | func diffNote(d *pb.Diff, shown, total int) string { | |
| 171 | + | if !d.GetTruncated() { | |
| 172 | + | return "" | |
| 173 | + | } | |
| 174 | + | var parts []string | |
| 175 | + | if shown < total { | |
| 176 | + | parts = append(parts, plural(total-shown, "file is", "files are")+" not listed") | |
| 177 | + | } | |
| 178 | + | cut := 0 | |
| 179 | + | for _, f := range d.GetFiles() { | |
| 180 | + | if f.GetTruncated() { | |
| 181 | + | cut++ | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | if cut > 0 { | |
| 185 | + | parts = append(parts, plural(cut, "file is", "files are")+" too large to show") | |
| 186 | + | } | |
| 187 | + | return "this diff is incomplete: " + strings.Join(parts, ", ") + ". the totals above count everything; clone the repository to read the rest." | |
| 188 | + | } | |
A internal/adapters/web/diff.go +288 -0
| @@ -0,0 +1,288 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "fmt" | |
| 8 | + | "html" | |
| 9 | + | "io" | |
| 10 | + | "slices" | |
| 11 | + | "strings" | |
| 12 | + | "unicode/utf8" | |
| 13 | + | ||
| 14 | + | "github.com/a-h/templ" | |
| 15 | + | ||
| 16 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Seg is a piece of a line of code: plain text, or the visible marker of an | |
| 20 | + | // invisible character (Trojan Source, ADR 0017). | |
| 21 | + | type Seg struct { | |
| 22 | + | Text string | |
| 23 | + | Mark bool | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // DiffLine is one line of a hunk, in the unified view and, paired, in the | |
| 27 | + | // split view. | |
| 28 | + | type DiffLine struct { | |
| 29 | + | Kind string // "ctx", "add", "del" | |
| 30 | + | Mark string // " ", "+", "-": every change keeps its marker, not only a colour | |
| 31 | + | Old, New string // line numbers, "" when the side has none | |
| 32 | + | Segs []Seg | |
| 33 | + | NoEOL bool // the side ends without a line break | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SplitRow pairs a deleted line (left) with an added one (right); a context | |
| 37 | + | // line fills both sides. | |
| 38 | + | type SplitRow struct{ L, R *DiffLine } | |
| 39 | + | ||
| 40 | + | // DiffHunk is a changed region. | |
| 41 | + | type DiffHunk struct { | |
| 42 | + | Header string | |
| 43 | + | Lines []DiffLine | |
| 44 | + | Split []SplitRow | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Bar is the change bar of the file list, "+++--·····", in three runs so each | |
| 48 | + | // can carry its colour. | |
| 49 | + | type Bar struct{ Add, Del, Rest string } | |
| 50 | + | ||
| 51 | + | // DiffFile is one changed file. | |
| 52 | + | type DiffFile struct { | |
| 53 | + | ID string | |
| 54 | + | Status string // A D M R C T | |
| 55 | + | Path string // display path, "old → new" for a rename | |
| 56 | + | Add, Del int | |
| 57 | + | Bar Bar | |
| 58 | + | // Note is why there is no text to show, or what else changed: a binary | |
| 59 | + | // file, a mode change, a truncation, a similarity. | |
| 60 | + | Note string | |
| 61 | + | Open bool | |
| 62 | + | Hunks []DiffHunk | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func lineNumber(n int32) string { | |
| 66 | + | if n <= 0 { | |
| 67 | + | return "" | |
| 68 | + | } | |
| 69 | + | return itoa(int(n)) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // segments splits a line of code into plain text and markers. One trailing | |
| 73 | + | // carriage return, the terminator of a CRLF file, is dropped. | |
| 74 | + | func segments(text string) []Seg { | |
| 75 | + | text = strings.TrimSuffix(text, "\r") | |
| 76 | + | if !strings.ContainsFunc(text, invisible) { | |
| 77 | + | return []Seg{{Text: text}} | |
| 78 | + | } | |
| 79 | + | var out []Seg | |
| 80 | + | start := 0 | |
| 81 | + | for i, r := range text { | |
| 82 | + | if !invisible(r) { | |
| 83 | + | continue | |
| 84 | + | } | |
| 85 | + | if i > start { | |
| 86 | + | out = append(out, Seg{Text: text[start:i]}) | |
| 87 | + | } | |
| 88 | + | out = append(out, Seg{Text: marker(r), Mark: true}) | |
| 89 | + | start = i + utf8.RuneLen(r) | |
| 90 | + | } | |
| 91 | + | if start < len(text) { | |
| 92 | + | out = append(out, Seg{Text: text[start:]}) | |
| 93 | + | } | |
| 94 | + | return out | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // messageText is a commit message for display: line breaks normalised, the | |
| 98 | + | // invisible characters shown. | |
| 99 | + | func messageText(s string) string { | |
| 100 | + | s = strings.ReplaceAll(s, "\r\n", "\n") | |
| 101 | + | return visibleText(strings.TrimRight(s, "\n")) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // hasMarker reports whether a line shows a marker of an invisible character. | |
| 105 | + | func hasMarker(l DiffLine) bool { | |
| 106 | + | return slices.ContainsFunc(l.Segs, func(s Seg) bool { return s.Mark }) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // pairLines builds the split view: a run of deletions lines up row by row | |
| 110 | + | // with the run of additions that follows it; context fills both sides. | |
| 111 | + | func pairLines(ls []DiffLine) []SplitRow { | |
| 112 | + | var out []SplitRow | |
| 113 | + | for i := 0; i < len(ls); { | |
| 114 | + | if ls[i].Kind == "ctx" { | |
| 115 | + | out = append(out, SplitRow{&ls[i], &ls[i]}) | |
| 116 | + | i++ | |
| 117 | + | continue | |
| 118 | + | } | |
| 119 | + | var dels, adds []*DiffLine | |
| 120 | + | for ; i < len(ls) && ls[i].Kind == "del"; i++ { | |
| 121 | + | dels = append(dels, &ls[i]) | |
| 122 | + | } | |
| 123 | + | for ; i < len(ls) && ls[i].Kind == "add"; i++ { | |
| 124 | + | adds = append(adds, &ls[i]) | |
| 125 | + | } | |
| 126 | + | for j := 0; j < len(dels) || j < len(adds); j++ { | |
| 127 | + | var row SplitRow | |
| 128 | + | if j < len(dels) { | |
| 129 | + | row.L = dels[j] | |
| 130 | + | } | |
| 131 | + | if j < len(adds) { | |
| 132 | + | row.R = adds[j] | |
| 133 | + | } | |
| 134 | + | out = append(out, row) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return out | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | // changeBar draws the change bar of a file, scaled to the largest file. | |
| 141 | + | func changeBar(add, del, most int) Bar { | |
| 142 | + | const cells = 10 | |
| 143 | + | if most == 0 { | |
| 144 | + | return Bar{Rest: strings.Repeat("·", cells)} | |
| 145 | + | } | |
| 146 | + | scale := func(n int) int { | |
| 147 | + | c := (n*cells + most - 1) / most | |
| 148 | + | if n > 0 && c == 0 { | |
| 149 | + | c = 1 | |
| 150 | + | } | |
| 151 | + | return c | |
| 152 | + | } | |
| 153 | + | a, d := scale(add), scale(del) | |
| 154 | + | if a+d > cells { | |
| 155 | + | d = cells - a | |
| 156 | + | } | |
| 157 | + | return Bar{strings.Repeat("+", a), strings.Repeat("-", d), strings.Repeat("·", cells-a-d)} | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | // statusLetter is the one-letter status of a file. | |
| 161 | + | func statusLetter(s pb.FileStatus) string { | |
| 162 | + | switch s { | |
| 163 | + | case pb.FileStatus_FILE_STATUS_ADDED: | |
| 164 | + | return "A" | |
| 165 | + | case pb.FileStatus_FILE_STATUS_DELETED: | |
| 166 | + | return "D" | |
| 167 | + | case pb.FileStatus_FILE_STATUS_RENAMED: | |
| 168 | + | return "R" | |
| 169 | + | case pb.FileStatus_FILE_STATUS_COPIED: | |
| 170 | + | return "C" | |
| 171 | + | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | |
| 172 | + | return "T" | |
| 173 | + | } | |
| 174 | + | return "M" | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // hunkHeader is the "@@ -a,b +c,d @@ context" line of a hunk. | |
| 178 | + | func hunkHeader(h *pb.Hunk) string { | |
| 179 | + | s := fmt.Sprintf("@@ -%d,%d +%d,%d @@", h.GetOldStart(), h.GetOldLines(), h.GetNewStart(), h.GetNewLines()) | |
| 180 | + | if t := h.GetHeader(); t != "" { | |
| 181 | + | s += " " + visibleText(strings.TrimSpace(t)) | |
| 182 | + | } | |
| 183 | + | return s | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // diffLine converts a line of the API. | |
| 187 | + | func diffLine(l *pb.Line) DiffLine { | |
| 188 | + | dl := DiffLine{Segs: segments(l.GetText()), NoEOL: l.GetNoNewlineAtEof()} | |
| 189 | + | switch l.GetKind() { | |
| 190 | + | case pb.LineKind_LINE_KIND_ADDITION: | |
| 191 | + | dl.Kind, dl.Mark, dl.New = "add", "+", lineNumber(l.GetNewNumber()) | |
| 192 | + | case pb.LineKind_LINE_KIND_DELETION: | |
| 193 | + | dl.Kind, dl.Mark, dl.Old = "del", "-", lineNumber(l.GetOldNumber()) | |
| 194 | + | default: | |
| 195 | + | dl.Kind, dl.Mark = "ctx", " " | |
| 196 | + | dl.Old, dl.New = lineNumber(l.GetOldNumber()), lineNumber(l.GetNewNumber()) | |
| 197 | + | } | |
| 198 | + | return dl | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // diffFiles converts the files of a diff for the page. | |
| 202 | + | func diffFiles(files []*pb.FileDiff) []DiffFile { | |
| 203 | + | most := 0 | |
| 204 | + | for _, f := range files { | |
| 205 | + | most = max(most, int(f.GetAdditions()+f.GetDeletions())) | |
| 206 | + | } | |
| 207 | + | out := make([]DiffFile, 0, len(files)) | |
| 208 | + | for i, f := range files { | |
| 209 | + | df := DiffFile{ | |
| 210 | + | ID: "f" + itoa(i+1), Status: statusLetter(f.GetStatus()), | |
| 211 | + | Add: int(f.GetAdditions()), Del: int(f.GetDeletions()), | |
| 212 | + | Bar: changeBar(int(f.GetAdditions()), int(f.GetDeletions()), most), | |
| 213 | + | // A file that is shown in full opens; a file with nothing to read | |
| 214 | + | // (binary, truncated) stays closed under its note. | |
| 215 | + | Open: len(f.GetHunks()) > 0 && !f.GetTruncated(), | |
| 216 | + | } | |
| 217 | + | switch f.GetStatus() { | |
| 218 | + | case pb.FileStatus_FILE_STATUS_DELETED: | |
| 219 | + | df.Path = visibleText(f.GetOldPath()) | |
| 220 | + | case pb.FileStatus_FILE_STATUS_RENAMED, pb.FileStatus_FILE_STATUS_COPIED: | |
| 221 | + | df.Path = visibleText(f.GetOldPath()) + " → " + visibleText(f.GetNewPath()) | |
| 222 | + | default: | |
| 223 | + | df.Path = visibleText(f.GetNewPath()) | |
| 224 | + | } | |
| 225 | + | df.Note = fileNote(f) | |
| 226 | + | if f.GetBinary() { | |
| 227 | + | df.Bar = Bar{Rest: "bin"} | |
| 228 | + | } | |
| 229 | + | for _, h := range f.GetHunks() { | |
| 230 | + | dh := DiffHunk{Header: hunkHeader(h)} | |
| 231 | + | for _, l := range h.GetLines() { | |
| 232 | + | dh.Lines = append(dh.Lines, diffLine(l)) | |
| 233 | + | } | |
| 234 | + | dh.Split = pairLines(dh.Lines) | |
| 235 | + | df.Hunks = append(df.Hunks, dh) | |
| 236 | + | } | |
| 237 | + | out = append(out, df) | |
| 238 | + | } | |
| 239 | + | return out | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // fileNote says what the hunks cannot: the kind of change when there is no | |
| 243 | + | // text to show, the mode change, the similarity of a rename. | |
| 244 | + | func fileNote(f *pb.FileDiff) string { | |
| 245 | + | var parts []string | |
| 246 | + | switch { | |
| 247 | + | case f.GetBinary(): | |
| 248 | + | parts = append(parts, "binary file: no text diff") | |
| 249 | + | case f.GetTruncated(): | |
| 250 | + | parts = append(parts, fmt.Sprintf("diff too large to show (%d lines changed): collapsed", f.GetAdditions()+f.GetDeletions())) | |
| 251 | + | } | |
| 252 | + | if f.GetOldMode() != 0 && f.GetNewMode() != 0 && f.GetOldMode() != f.GetNewMode() { | |
| 253 | + | parts = append(parts, fmt.Sprintf("mode %06o → %06o", f.GetOldMode(), f.GetNewMode())) | |
| 254 | + | } | |
| 255 | + | switch f.GetStatus() { | |
| 256 | + | case pb.FileStatus_FILE_STATUS_RENAMED: | |
| 257 | + | parts = append(parts, fmt.Sprintf("renamed, %d%% similar", f.GetSimilarity())) | |
| 258 | + | case pb.FileStatus_FILE_STATUS_COPIED: | |
| 259 | + | parts = append(parts, fmt.Sprintf("copied, %d%% similar", f.GetSimilarity())) | |
| 260 | + | case pb.FileStatus_FILE_STATUS_TYPE_CHANGED: | |
| 261 | + | parts = append(parts, "type changed") | |
| 262 | + | } | |
| 263 | + | return strings.Join(parts, " · ") | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | // noEOL marks a side that ends without a line break. | |
| 267 | + | const noEOL = "\u23ce\u0338" | |
| 268 | + | ||
| 269 | + | // codeLine renders the text of a line of code. It writes exactly the text, | |
| 270 | + | // escaped, and the markers: code cells keep their white space (pre), so the | |
| 271 | + | // spaces a template adds between expressions would show up in the code. | |
| 272 | + | func codeLine(ln DiffLine) templ.Component { | |
| 273 | + | return templ.ComponentFunc(func(_ context.Context, w io.Writer) error { | |
| 274 | + | var b strings.Builder | |
| 275 | + | for _, s := range ln.Segs { | |
| 276 | + | if s.Mark { | |
| 277 | + | b.WriteString(`<span class="inv" title="invisible character">` + html.EscapeString(s.Text) + `</span>`) | |
| 278 | + | } else { | |
| 279 | + | b.WriteString(html.EscapeString(s.Text)) | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | if ln.NoEOL { | |
| 283 | + | b.WriteString(`<span class="eol" title="no newline at end of file">` + noEOL + `</span>`) | |
| 284 | + | } | |
| 285 | + | _, err := io.WriteString(w, b.String()) | |
| 286 | + | return err | |
| 287 | + | }) | |
| 288 | + | } | |
A internal/adapters/web/diff.templ +234 -0
| @@ -0,0 +1,234 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | // Commit is a commit: the message and facts, the diff tools, the diff. | |
| 6 | + | templ Commit(l Layout, p CommitPage) { | |
| 7 | + | @Shell(l, commitRail(p)) { | |
| 8 | + | <header class="show"> | |
| 9 | + | <h1>{ p.Subject }</h1> | |
| 10 | + | if p.Body != "" { | |
| 11 | + | <pre class="msg">{ p.Body }</pre> | |
| 12 | + | } | |
| 13 | + | <dl class="kv show-kv"> | |
| 14 | + | <dt>commit</dt> | |
| 15 | + | <dd>{ p.SHA }</dd> | |
| 16 | + | <dt>author</dt> | |
| 17 | + | <dd>@personView(p.Author)</dd> | |
| 18 | + | if p.Committer != nil { | |
| 19 | + | <dt>committer</dt> | |
| 20 | + | <dd>@personView(*p.Committer)</dd> | |
| 21 | + | } | |
| 22 | + | <dt> | |
| 23 | + | if p.Merge { | |
| 24 | + | merge of | |
| 25 | + | } else { | |
| 26 | + | parent | |
| 27 | + | } | |
| 28 | + | </dt> | |
| 29 | + | <dd> | |
| 30 | + | if p.Root { | |
| 31 | + | none (root commit) | |
| 32 | + | } | |
| 33 | + | for i, par := range p.Parents { | |
| 34 | + | if i > 0 { | |
| 35 | + | { " + " } | |
| 36 | + | } | |
| 37 | + | <a href={ templ.SafeURL(par.Href) }>{ par.Label }</a> | |
| 38 | + | } | |
| 39 | + | if p.Merge { | |
| 40 | + | { " · diff against the first parent" } | |
| 41 | + | } | |
| 42 | + | </dd> | |
| 43 | + | if len(p.Refs) > 0 { | |
| 44 | + | <dt>refs</dt> | |
| 45 | + | <dd> | |
| 46 | + | for _, r := range p.Refs { | |
| 47 | + | [{ r }]{ " " } | |
| 48 | + | } | |
| 49 | + | </dd> | |
| 50 | + | } | |
| 51 | + | </dl> | |
| 52 | + | </header> | |
| 53 | + | <div class="diff-tools" role="toolbar" aria-label="Diff view"> | |
| 54 | + | <span class="dim">{ plural(p.NFiles, "file", "files") } · <span class="plus">+{ itoa(p.Adds) }</span> <span class="minus">-{ itoa(p.Dels) }</span></span> | |
| 55 | + | <span class="grow"></span> | |
| 56 | + | @viewLink(p, "unified", "[u]nified", "u", p.UnifiedHref) | |
| 57 | + | @viewLink(p, "split", "[d] split", "d", p.SplitHref) | |
| 58 | + | <button type="button" aria-keyshortcuts="x" hx-on:click="document.body.dispatchEvent(new CustomEvent('k-collapse'))">[x] collapse all</button> | |
| 59 | + | </div> | |
| 60 | + | if p.Invisible { | |
| 61 | + | <p class="notice"> | |
| 62 | + | this diff holds invisible characters (direction controls, zero-width characters). They are shown as | |
| 63 | + | <span class="inv"><U+XXXX></span> markers, never applied. | |
| 64 | + | </p> | |
| 65 | + | } | |
| 66 | + | if p.Note != "" { | |
| 67 | + | <p class="notice">{ p.Note }</p> | |
| 68 | + | } | |
| 69 | + | <div | |
| 70 | + | id="diffs" | |
| 71 | + | if p.Both { | |
| 72 | + | data-diff={ p.View } | |
| 73 | + | } else { | |
| 74 | + | data-view={ p.View } | |
| 75 | + | } | |
| 76 | + | if p.Both && !p.Pinned { | |
| 77 | + | hx-live="this.dataset.diff = sessionStorage.getItem('diff') || this.dataset.diff" | |
| 78 | + | } | |
| 79 | + | > | |
| 80 | + | for _, f := range p.Files { | |
| 81 | + | @DiffFileView(f, p) | |
| 82 | + | } | |
| 83 | + | if len(p.Files) == 0 { | |
| 84 | + | <p class="note">no changed files{ " " } | |
| 85 | + | if p.Merge { | |
| 86 | + | against the first parent. | |
| 87 | + | } else { | |
| 88 | + | in this commit. | |
| 89 | + | } | |
| 90 | + | </p> | |
| 91 | + | } | |
| 92 | + | </div> | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // viewLink is a button of the diff tools. It is a link, so that the split | |
| 97 | + | // layout works without JavaScript; with it, and when both layouts are in the | |
| 98 | + | // page, a click only flips the layout. | |
| 99 | + | templ viewLink(p CommitPage, view, label, key, href string) { | |
| 100 | + | <a | |
| 101 | + | href={ templ.SafeURL(href) } | |
| 102 | + | class="vbtn" | |
| 103 | + | data-view={ view } | |
| 104 | + | aria-keyshortcuts={ key } | |
| 105 | + | if p.View == view { | |
| 106 | + | aria-current="true" | |
| 107 | + | } | |
| 108 | + | if p.Both { | |
| 109 | + | hx-on:click="event.preventDefault(); document.body.dispatchEvent(new CustomEvent('k-' + this.dataset.view))" | |
| 110 | + | :aria-current={ "q('#diffs').attr['data-diff'] === '" + view + "' ? 'true' : false" } | |
| 111 | + | } | |
| 112 | + | >{ label }</a> | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | templ personView(pr Person) { | |
| 116 | + | { pr.Name } | |
| 117 | + | if pr.WhenAt != "" { | |
| 118 | + | { " · " }<time datetime={ pr.WhenAt } title={ pr.Abs }>{ pr.When }</time>{ " · " + pr.Abs } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | templ commitRail(p CommitPage) { | |
| 123 | + | <section aria-labelledby="r-files"> | |
| 124 | + | <h2 id="r-files">files <span>{ itoa(len(p.Files)) }</span></h2> | |
| 125 | + | @FileList(p.Files) | |
| 126 | + | </section> | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // FileList is the table of contents of a diff: status letter, path, +n -m | |
| 130 | + | // and an ASCII bar. The row of the file in view is marked. | |
| 131 | + | templ FileList(files []DiffFile) { | |
| 132 | + | <ol class="files" aria-label="Changed files"> | |
| 133 | + | for _, f := range files { | |
| 134 | + | <li> | |
| 135 | + | <a | |
| 136 | + | href={ templ.SafeURL("#" + f.ID) } | |
| 137 | + | :aria-current={ "(() => { let all = q('.dfile').arr(); let cur = all.filter(f => f.getBoundingClientRect().top < innerHeight / 3).at(-1) || all[0]; return cur?.id === '" + f.ID + "' ? 'true' : false })()" } | |
| 138 | + | hx-on:click="document.getElementById(this.hash.slice(1)).open = true" | |
| 139 | + | > | |
| 140 | + | <span class="st" title={ f.Status }>{ f.Status }</span> | |
| 141 | + | <span class="p" title={ f.Path }>{ f.Path }</span> | |
| 142 | + | <span class="n"><span class="plus">+{ itoa(f.Add) }</span> <span class="minus">-{ itoa(f.Del) }</span></span> | |
| 143 | + | <span class="b" aria-hidden="true"><span class="plus">{ f.Bar.Add }</span><span class="minus">{ f.Bar.Del }</span>{ f.Bar.Rest }</span> | |
| 144 | + | </a> | |
| 145 | + | </li> | |
| 146 | + | } | |
| 147 | + | </ol> | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // DiffFileView is one file of the diff: a <details> (per-file collapse). The | |
| 151 | + | // unified and the split tables are both in it; #diffs[data-diff] shows one. | |
| 152 | + | // Every changed line keeps its +/- marker, so a change is never conveyed by | |
| 153 | + | // colour alone. | |
| 154 | + | templ DiffFileView(f DiffFile, p CommitPage) { | |
| 155 | + | <details | |
| 156 | + | class="dfile" | |
| 157 | + | id={ f.ID } | |
| 158 | + | data-file | |
| 159 | + | if f.Open { | |
| 160 | + | open | |
| 161 | + | } | |
| 162 | + | > | |
| 163 | + | <summary> | |
| 164 | + | <span class="st">{ f.Status }</span>{ " " }<span class="p">{ f.Path }</span>{ " " }<span class="plus">+{ itoa(f.Add) }</span>{ " " }<span class="minus">-{ itoa(f.Del) }</span> | |
| 165 | + | if f.Note != "" { | |
| 166 | + | { " " }<span class="note">{ f.Note }</span> | |
| 167 | + | } | |
| 168 | + | </summary> | |
| 169 | + | if len(f.Hunks) > 0 { | |
| 170 | + | <div class="dscroll"> | |
| 171 | + | if p.ShowUnified { | |
| 172 | + | <table class="dt uni" aria-label={ f.Path + ", unified" }> | |
| 173 | + | for _, h := range f.Hunks { | |
| 174 | + | <tbody> | |
| 175 | + | <tr class="hunk"><td colspan="4">{ h.Header }</td></tr> | |
| 176 | + | for _, ln := range h.Lines { | |
| 177 | + | <tr class={ ln.Kind }> | |
| 178 | + | <td class="ln">{ ln.Old }</td> | |
| 179 | + | <td class="ln">{ ln.New }</td> | |
| 180 | + | <td class="m">{ ln.Mark }</td> | |
| 181 | + | <td class="tx">@codeLine(ln)</td> | |
| 182 | + | </tr> | |
| 183 | + | } | |
| 184 | + | </tbody> | |
| 185 | + | } | |
| 186 | + | </table> | |
| 187 | + | } | |
| 188 | + | if p.ShowSplit { | |
| 189 | + | <table class="dt spl" aria-label={ f.Path + ", split" }> | |
| 190 | + | for _, h := range f.Hunks { | |
| 191 | + | <tbody> | |
| 192 | + | <tr class="hunk"><td colspan="6">{ h.Header }</td></tr> | |
| 193 | + | for _, row := range h.Split { | |
| 194 | + | <tr> | |
| 195 | + | @splitSide(row.L, true) | |
| 196 | + | @splitSide(row.R, false) | |
| 197 | + | </tr> | |
| 198 | + | } | |
| 199 | + | </tbody> | |
| 200 | + | } | |
| 201 | + | </table> | |
| 202 | + | } | |
| 203 | + | </div> | |
| 204 | + | } else { | |
| 205 | + | <p class="note pad"> | |
| 206 | + | if f.Note != "" { | |
| 207 | + | { f.Note } | |
| 208 | + | } else { | |
| 209 | + | no textual change. | |
| 210 | + | } | |
| 211 | + | </p> | |
| 212 | + | } | |
| 213 | + | </details> | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // splitSide is one half of a row of the split view; a side without a line is | |
| 217 | + | // an empty cell. | |
| 218 | + | templ splitSide(ln *DiffLine, old bool) { | |
| 219 | + | if ln == nil { | |
| 220 | + | <td class="ln none"></td> | |
| 221 | + | <td class="m none"></td> | |
| 222 | + | <td class="tx none"></td> | |
| 223 | + | } else { | |
| 224 | + | <td class={ "ln", ln.Kind }> | |
| 225 | + | if old { | |
| 226 | + | { ln.Old } | |
| 227 | + | } else { | |
| 228 | + | { ln.New } | |
| 229 | + | } | |
| 230 | + | </td> | |
| 231 | + | <td class={ "m", ln.Kind }>{ ln.Mark }</td> | |
| 232 | + | <td class={ "tx", ln.Kind }>@codeLine(*ln)</td> | |
| 233 | + | } | |
| 234 | + | } | |
A internal/adapters/web/diff_templ.go +1425 -0 generated file: collapsed
A internal/adapters/web/diff_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // Trojan Source (ADR 0017): direction controls, zero-width and control | |
| 17 | + | // characters are shown as markers; ordinary text, tabs and non-Latin letters | |
| 18 | + | // are not touched. | |
| 19 | + | func TestSegmentsShowInvisibleCharacters(t *testing.T) { | |
| 20 | + | tests := []struct { | |
| 21 | + | name, in string | |
| 22 | + | want []Seg | |
| 23 | + | }{ | |
| 24 | + | {"plain", "return x", []Seg{{Text: "return x"}}}, | |
| 25 | + | {"tab and unicode", "\tπ := “é”", []Seg{{Text: "\tπ := “é”"}}}, | |
| 26 | + | {"empty", "", []Seg{{Text: ""}}}, | |
| 27 | + | {"crlf", "a\r", []Seg{{Text: "a"}}}, | |
| 28 | + | {"rlo", "if x { // \u202eadmin", []Seg{{Text: "if x { // "}, {Text: "<U+202E>", Mark: true}, {Text: "admin"}}}, | |
| 29 | + | {"isolates", "\u2066a\u2069", []Seg{{Text: "<U+2066>", Mark: true}, {Text: "a"}, {Text: "<U+2069>", Mark: true}}}, | |
| 30 | + | {"zero width", "a\u200bb\ufeff", []Seg{{Text: "a"}, {Text: "<U+200B>", Mark: true}, {Text: "b"}, {Text: "<U+FEFF>", Mark: true}}}, | |
| 31 | + | {"control", "a\x00\x1bb", []Seg{{Text: "a"}, {Text: "<U+0000>", Mark: true}, {Text: "<U+001B>", Mark: true}, {Text: "b"}}}, | |
| 32 | + | {"tag character", "a\U000E0041", []Seg{{Text: "a"}, {Text: "<U+E0041>", Mark: true}}}, | |
| 33 | + | {"hangul filler", "\u3164", []Seg{{Text: "<U+3164>", Mark: true}}}, | |
| 34 | + | {"replacement is visible already", "a\ufffdb", []Seg{{Text: "a\ufffdb"}}}, | |
| 35 | + | } | |
| 36 | + | for _, tt := range tests { | |
| 37 | + | t.Run(tt.name, func(t *testing.T) { | |
| 38 | + | assert.Equal(t, tt.want, segments(tt.in)) | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | assert.Equal(t, "a<U+202E>b.go", visibleText("a\u202eb.go")) | |
| 42 | + | assert.Equal(t, "plain", visibleText("plain")) | |
| 43 | + | assert.Equal(t, "l1\nl<U+200B>2", messageText("l1\r\nl\u200b2\n\n")) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func line(kind pb.LineKind, oldN, newN int32, text string) *pb.Line { | |
| 47 | + | return &pb.Line{Kind: kind, OldNumber: oldN, NewNumber: newN, Text: text} | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // The split view lines a run of deletions up with the additions after it; | |
| 51 | + | // context fills both sides; the extra lines of the longer run have an empty | |
| 52 | + | // opposite side. | |
| 53 | + | func TestPairLines(t *testing.T) { | |
| 54 | + | ctx, add, del := pb.LineKind_LINE_KIND_CONTEXT, pb.LineKind_LINE_KIND_ADDITION, pb.LineKind_LINE_KIND_DELETION | |
| 55 | + | var ls []DiffLine | |
| 56 | + | for _, l := range []*pb.Line{ | |
| 57 | + | line(ctx, 1, 1, "c"), | |
| 58 | + | line(del, 2, 0, "d1"), line(del, 3, 0, "d2"), line(del, 4, 0, "d3"), | |
| 59 | + | line(add, 0, 2, "a1"), | |
| 60 | + | line(ctx, 5, 3, "c2"), | |
| 61 | + | line(add, 0, 4, "a2"), line(add, 0, 5, "a3"), | |
| 62 | + | } { | |
| 63 | + | ls = append(ls, diffLine(l)) | |
| 64 | + | } | |
| 65 | + | rows := pairLines(ls) | |
| 66 | + | type pair struct{ l, r string } | |
| 67 | + | var got []pair | |
| 68 | + | for _, r := range rows { | |
| 69 | + | p := pair{} | |
| 70 | + | if r.L != nil { | |
| 71 | + | p.l = r.L.Mark + r.L.Segs[0].Text | |
| 72 | + | } | |
| 73 | + | if r.R != nil { | |
| 74 | + | p.r = r.R.Mark + r.R.Segs[0].Text | |
| 75 | + | } | |
| 76 | + | got = append(got, p) | |
| 77 | + | } | |
| 78 | + | assert.Equal(t, []pair{ | |
| 79 | + | {" c", " c"}, {"-d1", "+a1"}, {"-d2", ""}, {"-d3", ""}, {" c2", " c2"}, {"", "+a2"}, {"", "+a3"}, | |
| 80 | + | }, got) | |
| 81 | + | assert.Equal(t, DiffLine{Kind: "del", Mark: "-", Old: "2", Segs: []Seg{{Text: "d1"}}}, ls[1]) | |
| 82 | + | assert.Equal(t, DiffLine{Kind: "add", Mark: "+", New: "2", Segs: []Seg{{Text: "a1"}}}, ls[4]) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestChangeBar(t *testing.T) { | |
| 86 | + | assert.Equal(t, Bar{Rest: "··········"}, changeBar(0, 0, 0)) | |
| 87 | + | assert.Equal(t, Bar{Add: "++++++++++"}, changeBar(100, 0, 100)) | |
| 88 | + | assert.Equal(t, Bar{Add: "+++++", Del: "-----", Rest: ""}, changeBar(50, 50, 100)) | |
| 89 | + | assert.Equal(t, Bar{Add: "+", Rest: "·········"}, changeBar(1, 0, 1000), "a change is never rounded away") | |
| 90 | + | assert.Equal(t, 10, len(changeBar(7, 7, 8).Add+changeBar(7, 7, 8).Del+changeBar(7, 7, 8).Rest), "never wider than the bar") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestFileNotes(t *testing.T) { | |
| 94 | + | assert.Equal(t, "binary file: no text diff", fileNote(&pb.FileDiff{Binary: true, Status: pb.FileStatus_FILE_STATUS_MODIFIED})) | |
| 95 | + | assert.Equal(t, "mode 100644 → 100755", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_MODIFIED, OldMode: 0o100644, NewMode: 0o100755})) | |
| 96 | + | assert.Empty(t, fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_ADDED, NewMode: 0o100644}), "an added file has no mode change") | |
| 97 | + | assert.Equal(t, "renamed, 90% similar", fileNote(&pb.FileDiff{Status: pb.FileStatus_FILE_STATUS_RENAMED, Similarity: 90, OldMode: 0o100644, NewMode: 0o100644})) | |
| 98 | + | assert.Contains(t, fileNote(&pb.FileDiff{Truncated: true, Additions: 2500}), "too large") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func TestDiffFilesPaths(t *testing.T) { | |
| 102 | + | fs := diffFiles([]*pb.FileDiff{ | |
| 103 | + | {Status: pb.FileStatus_FILE_STATUS_ADDED, NewPath: "a.go", Additions: 1, Hunks: []*pb.Hunk{{Lines: []*pb.Line{line(pb.LineKind_LINE_KIND_ADDITION, 0, 1, "x")}}}}, | |
| 104 | + | {Status: pb.FileStatus_FILE_STATUS_DELETED, OldPath: "b.go", Deletions: 1}, | |
| 105 | + | {Status: pb.FileStatus_FILE_STATUS_RENAMED, OldPath: "c\u202e.go", NewPath: "d.go"}, | |
| 106 | + | {Status: pb.FileStatus_FILE_STATUS_MODIFIED, NewPath: "big.go", Truncated: true, Additions: 3000}, | |
| 107 | + | }) | |
| 108 | + | assert.Equal(t, "a.go", fs[0].Path) | |
| 109 | + | assert.True(t, fs[0].Open) | |
| 110 | + | assert.Equal(t, "b.go", fs[1].Path) | |
| 111 | + | assert.False(t, fs[1].Open, "nothing to read: stays closed") | |
| 112 | + | assert.Equal(t, "c<U+202E>.go → d.go", fs[2].Path) | |
| 113 | + | assert.False(t, fs[3].Open, "a truncated file is collapsed") | |
| 114 | + | assert.Equal(t, []string{"f1", "f2", "f3", "f4"}, []string{fs[0].ID, fs[1].ID, fs[2].ID, fs[3].ID}) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // A code cell holds exactly the text: escaped, with markers, and nothing | |
| 118 | + | // added between the pieces (the cell keeps its white space). | |
| 119 | + | func TestCodeLine(t *testing.T) { | |
| 120 | + | var b strings.Builder | |
| 121 | + | ln := diffLine(&pb.Line{Kind: pb.LineKind_LINE_KIND_ADDITION, NewNumber: 1, Text: "a <b> \u202e c d", NoNewlineAtEof: true}) | |
| 122 | + | require.NoError(t, codeLine(ln).Render(context.Background(), &b)) | |
| 123 | + | assert.Equal(t, `a <b> <span class="inv" title="invisible character"><U+202E></span> c d`+ | |
| 124 | + | `<span class="eol" title="no newline at end of file">`+noEOL+`</span>`, b.String()) | |
| 125 | + | } | |
A internal/adapters/web/static/css/55-diff.css +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | @layer components { | |
| 2 | + | /* an invisible character in code, shown instead of applied (Trojan Source) */ | |
| 3 | + | .inv { | |
| 4 | + | color: var(--red); font-weight: bold; font-size: var(--fs-s); | |
| 5 | + | outline: 1px solid var(--red); outline-offset: -1px; padding: 0 .25ch; | |
| 6 | + | } | |
| 7 | + | .eol { color: var(--ink-mute); padding-left: 1ch; user-select: none; } | |
| 8 | + | /* the layout links of the diff tools read as the buttons of the mockup */ | |
| 9 | + | .diff-tools a.vbtn { color: var(--ink-dim); text-decoration: none; } | |
| 10 | + | .diff-tools a.vbtn:hover { color: var(--ink); text-decoration: underline; } | |
| 11 | + | .diff-tools a.vbtn[aria-current="true"] { color: var(--ink-hi); font-weight: bold; } | |
| 12 | + | .diff-tools a.vbtn[aria-current="true"]::before { content: "▸"; } | |
| 13 | + | } | |
M internal/adapters/web/web.go +1 -0
| @@ -100,6 +100,7 @@ func (s *Server) routes() *http.ServeMux { | |||
| 100 | 100 | mux.HandleFunc("GET /{repo}/refs", s.page(s.refs)) | |
| 101 | 101 | mux.HandleFunc("GET /{repo}/commits", s.page(s.commits)) | |
| 102 | 102 | mux.HandleFunc("GET /{repo}/commits/{rest...}", s.page(s.commits)) | |
| 103 | + | mux.HandleFunc("GET /{repo}/commit/{sha}", s.page(s.commit)) | |
| 103 | 104 | mux.HandleFunc("GET /{repo}/{rest...}", s.page(s.notFound)) | |
| 104 | 105 | return mux | |
| 105 | 106 | } | |