feat(cmd): serve the web on the in-process API as an anonymous visitor
Golden pages, private-repo invisibility, README neutralisation and CSP/asset checks run against a real forge.
- commit
- b90cbe4a91e7e7f55eaf06889b178dcedab6929b
- author
- brice · · 2026-10-10 12:33 UTC
- committer
- brice · · 2026-10-10 12:34 UTC
- parent
- 5cd052a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +2071 -6
M README.md +1 -1
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | 3 | A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface. | |
| 4 | 4 | ||
| 5 | - | Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push) and answers `GET /-/health`. There is no web interface or token command yet. | |
| 5 | + | Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), answers `GET /-/health`, and serves the read-only web: the home list, the summary of a repository (README, clone URL, facts, recent commits) and its refs. Tree, file, commit and live pages are still to come. | |
| 6 | 6 | ||
| 7 | 7 | ## Run | |
| 8 | 8 | ||
| @@ -2,7 +2,7 @@ | |||||
| 2 | 2 | ||||
| 3 | A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface. | 3 | A single-user Git forge in one Go binary. ermite hosts bare repositories, serves clone and push over HTTPS, and shows them in a read-only web interface. | ||
| 4 | 4 | ||||
| 5 | - | Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push) and answers `GET /-/health`. There is no web interface or token command yet. | 5 | + | Status: early development. `ermite serve` serves Git over HTTP (clone, fetch, push), answers `GET /-/health`, and serves the read-only web: the home list, the summary of a repository (README, clone URL, facts, recent commits) and its refs. Tree, file, commit and live pages are still to come. |
| 6 | 6 | ||||
| 7 | ## Run | 7 | ## Run | ||
| 8 | 8 | ||||
M cmd/ermite/e2e_test.go +9 -4
| @@ -91,7 +91,8 @@ func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) { | |||
| 91 | 91 | require.NoError(t, err, out) | |
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | - | // Only the smart HTTP routes exist; the rest of the repository tree is 404. | |
| 94 | + | // Only the smart HTTP routes exist under a repository's .git URL; the rest of | |
| 95 | + | // the repository tree is the 404 page of the web. | |
| 95 | 96 | func TestRoutes(t *testing.T) { | |
| 96 | 97 | h := newHarness(t, harnessOpts{}) | |
| 97 | 98 | h.publicRepo("pub", "a") | |
| @@ -102,8 +103,7 @@ func TestRoutes(t *testing.T) { | |||
| 102 | 103 | "/pub.git/info/refs", // dumb protocol: no service | |
| 103 | 104 | "/pub.git/info/refs?service=git-bogus", | |
| 104 | 105 | "/pub.git/config", | |
| 105 | - | "/pub/tree/main", | |
| 106 | - | "/", | |
| 106 | + | "/pub/tree/main", // a page of a later phase | |
| 107 | 107 | "/-/nothing", | |
| 108 | 108 | "/Bad.git/info/refs?service=git-upload-pack", | |
| 109 | 109 | "/..%2f..%2fetc/info/refs?service=git-upload-pack", | |
| @@ -111,7 +111,12 @@ func TestRoutes(t *testing.T) { | |||
| 111 | 111 | assert.Equal(t, 404, h.status("GET", path, nil), path) | |
| 112 | 112 | } | |
| 113 | 113 | assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | |
| 114 | - | assert.Equal(t, 405, h.status("GET", "/pub.git/git-upload-pack", nil)) | |
| 114 | + | // The web owns GET on every other path: a GET of a POST-only Git route | |
| 115 | + | // is its 404 page, not a 405. | |
| 116 | + | assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil)) | |
| 117 | + | assert.Equal(t, 200, h.status("GET", "/", nil), "the home page") | |
| 118 | + | assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page") | |
| 119 | + | assert.Equal(t, 405, h.status("POST", "/", nil)) | |
| 115 | 120 | assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact") | |
| 116 | 121 | assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil)) | |
| 117 | 122 | // Anonymous receive-pack is a challenge even for a public repository. | |
| @@ -91,7 +91,8 @@ func TestAC1AnonymousCloneAndPrivateRepo(t *testing.T) { | |||||
| 91 | require.NoError(t, err, out) | 91 | require.NoError(t, err, out) | ||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | - | // Only the smart HTTP routes exist; the rest of the repository tree is 404. | 94 | + | // Only the smart HTTP routes exist under a repository's .git URL; the rest of |
| 95 | + | // the repository tree is the 404 page of the web. | |||
| 95 | func TestRoutes(t *testing.T) { | 96 | func TestRoutes(t *testing.T) { | ||
| 96 | h := newHarness(t, harnessOpts{}) | 97 | h := newHarness(t, harnessOpts{}) | ||
| 97 | h.publicRepo("pub", "a") | 98 | h.publicRepo("pub", "a") | ||
| @@ -102,8 +103,7 @@ func TestRoutes(t *testing.T) { | |||||
| 102 | "/pub.git/info/refs", // dumb protocol: no service | 103 | "/pub.git/info/refs", // dumb protocol: no service | ||
| 103 | "/pub.git/info/refs?service=git-bogus", | 104 | "/pub.git/info/refs?service=git-bogus", | ||
| 104 | "/pub.git/config", | 105 | "/pub.git/config", | ||
| 105 | - | "/pub/tree/main", | 106 | + | "/pub/tree/main", // a page of a later phase |
| 106 | - | "/", | |||
| 107 | "/-/nothing", | 107 | "/-/nothing", | ||
| 108 | "/Bad.git/info/refs?service=git-upload-pack", | 108 | "/Bad.git/info/refs?service=git-upload-pack", | ||
| 109 | "/..%2f..%2fetc/info/refs?service=git-upload-pack", | 109 | "/..%2f..%2fetc/info/refs?service=git-upload-pack", | ||
| @@ -111,7 +111,12 @@ func TestRoutes(t *testing.T) { | |||||
| 111 | assert.Equal(t, 404, h.status("GET", path, nil), path) | 111 | assert.Equal(t, 404, h.status("GET", path, nil), path) | ||
| 112 | } | 112 | } | ||
| 113 | assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | 113 | assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | ||
| 114 | - | assert.Equal(t, 405, h.status("GET", "/pub.git/git-upload-pack", nil)) | 114 | + | // The web owns GET on every other path: a GET of a POST-only Git route |
| 115 | + | // is its 404 page, not a 405. | |||
| 116 | + | assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil)) | |||
| 117 | + | assert.Equal(t, 200, h.status("GET", "/", nil), "the home page") | |||
| 118 | + | assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page") | |||
| 119 | + | assert.Equal(t, 405, h.status("POST", "/", nil)) | |||
| 115 | assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact") | 120 | assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact") | ||
| 116 | assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil)) | 121 | assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil)) | ||
| 117 | // Anonymous receive-pack is a challenge even for a public repository. | 122 | // Anonymous receive-pack is a challenge even for a public repository. | ||
M cmd/ermite/forge.go +7 -1
| @@ -19,6 +19,7 @@ import ( | |||
| 19 | 19 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 20 | 20 | "bnema.dev/ermite/internal/adapters/kvstore" | |
| 21 | 21 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 22 | + | "bnema.dev/ermite/internal/adapters/web" | |
| 22 | 23 | "bnema.dev/ermite/internal/app" | |
| 23 | 24 | "bnema.dev/ermite/internal/clientlimit" | |
| 24 | 25 | "bnema.dev/ermite/internal/config" | |
| @@ -175,10 +176,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 175 | 176 | Limiter: limiter, | |
| 176 | 177 | MaxBodyBytes: maxBody, | |
| 177 | 178 | }) | |
| 178 | - | routes := make([]httpserver.Route, 0, len(githttp.Patterns)) | |
| 179 | + | routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns)) | |
| 179 | 180 | for _, p := range githttp.Patterns { | |
| 180 | 181 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 181 | 182 | } | |
| 183 | + | site, err := newWeb(cfg, f.API) | |
| 184 | + | if err != nil { | |
| 185 | + | return nil, fmt.Errorf("web: %w", err) | |
| 186 | + | } | |
| 187 | + | routes = append(routes, webRoutes(site)...) | |
| 182 | 188 | f.server = httpserver.New(httpserver.Config{ | |
| 183 | 189 | Addr: cfg.Addr, | |
| 184 | 190 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | |
| @@ -19,6 +19,7 @@ import ( | |||||
| 19 | "bnema.dev/ermite/internal/adapters/httpserver" | 19 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 20 | "bnema.dev/ermite/internal/adapters/kvstore" | 20 | "bnema.dev/ermite/internal/adapters/kvstore" | ||
| 21 | "bnema.dev/ermite/internal/adapters/rpc" | 21 | "bnema.dev/ermite/internal/adapters/rpc" | ||
| 22 | + | "bnema.dev/ermite/internal/adapters/web" | |||
| 22 | "bnema.dev/ermite/internal/app" | 23 | "bnema.dev/ermite/internal/app" | ||
| 23 | "bnema.dev/ermite/internal/clientlimit" | 24 | "bnema.dev/ermite/internal/clientlimit" | ||
| 24 | "bnema.dev/ermite/internal/config" | 25 | "bnema.dev/ermite/internal/config" | ||
| @@ -175,10 +176,15 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 175 | Limiter: limiter, | 176 | Limiter: limiter, | ||
| 176 | MaxBodyBytes: maxBody, | 177 | MaxBodyBytes: maxBody, | ||
| 177 | }) | 178 | }) | ||
| 178 | - | routes := make([]httpserver.Route, 0, len(githttp.Patterns)) | 179 | + | routes := make([]httpserver.Route, 0, len(githttp.Patterns)+len(web.Patterns)) |
| 179 | for _, p := range githttp.Patterns { | 180 | for _, p := range githttp.Patterns { | ||
| 180 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | 181 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | ||
| 181 | } | 182 | } | ||
| 183 | + | site, err := newWeb(cfg, f.API) | |||
| 184 | + | if err != nil { | |||
| 185 | + | return nil, fmt.Errorf("web: %w", err) | |||
| 186 | + | } | |||
| 187 | + | routes = append(routes, webRoutes(site)...) | |||
| 182 | f.server = httpserver.New(httpserver.Config{ | 188 | f.server = httpserver.New(httpserver.Config{ | ||
| 183 | Addr: cfg.Addr, | 189 | Addr: cfg.Addr, | ||
| 184 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | 190 | HealthChecks: []httpserver.Check{store.Check, httpserver.DirWritable(cfg.DataDir)}, | ||
A cmd/ermite/testdata/web/empty.html +227 -0
| @@ -0,0 +1,227 @@ | |||
| 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>quiet · 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="" 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="/quiet" aria-current="page">quiet</a> | |
| 28 | + | </li> | |
| 29 | + | </ol> | |
| 30 | + | </nav> | |
| 31 | + | </header> | |
| 32 | + | <div class="work"> | |
| 33 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 34 | + | <section class="state" aria-labelledby="state-title"> | |
| 35 | + | <h1 id="state-title"> | |
| 36 | + | <span class="code">empty</span> quiet is empty</h1> | |
| 37 | + | <p class="dim">no commits yet. push a branch to create one.</p> | |
| 38 | + | <div class="clone" id="clone-quiet"> | |
| 39 | + | <div class="clone-line"> | |
| 40 | + | <span class="mute" aria-hidden="true">$ </span> | |
| 41 | + | <code>git remote add origin http://example.test/quiet.git</code> | |
| 42 | + | <button type="button" class="copy" data-label="[copy]" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button> | |
| 43 | + | </div> | |
| 44 | + | <div class="clone-line"> | |
| 45 | + | <span class="mute" aria-hidden="true">$ </span> | |
| 46 | + | <code>git push -u origin main</code> | |
| 47 | + | <button type="button" class="copy" data-label="[copy]" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button> | |
| 48 | + | </div> | |
| 49 | + | </div> | |
| 50 | + | <p class="state-back"> | |
| 51 | + | <a href="/">home</a> | |
| 52 | + | </p> | |
| 53 | + | </section> | |
| 54 | + | </main> | |
| 55 | + | </div> | |
| 56 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 57 | + | <div class="panel-head"> | |
| 58 | + | <span>keys</span> | |
| 59 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 60 | + | </div> | |
| 61 | + | <dl class="keys"> | |
| 62 | + | <div> | |
| 63 | + | <dt> | |
| 64 | + | <kbd>j</kbd> | |
| 65 | + | <kbd>k</kbd> | |
| 66 | + | </dt> | |
| 67 | + | <dd>move cursor down / up</dd> | |
| 68 | + | </div> | |
| 69 | + | <div> | |
| 70 | + | <dt> | |
| 71 | + | <kbd>enter</kbd> | |
| 72 | + | </dt> | |
| 73 | + | <dd>open row under cursor</dd> | |
| 74 | + | </div> | |
| 75 | + | <div> | |
| 76 | + | <dt> | |
| 77 | + | <kbd>g</kbd> | |
| 78 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 79 | + | </dt> | |
| 80 | + | <dd>first / last row</dd> | |
| 81 | + | </div> | |
| 82 | + | <div> | |
| 83 | + | <dt> | |
| 84 | + | <kbd>/</kbd> | |
| 85 | + | </dt> | |
| 86 | + | <dd>focus the filter</dd> | |
| 87 | + | </div> | |
| 88 | + | <div> | |
| 89 | + | <dt> | |
| 90 | + | <kbd>g h</kbd> | |
| 91 | + | <kbd>g t</kbd> | |
| 92 | + | </dt> | |
| 93 | + | <dd>go home / tree</dd> | |
| 94 | + | </div> | |
| 95 | + | <div> | |
| 96 | + | <dt> | |
| 97 | + | <kbd>g c</kbd> | |
| 98 | + | <kbd>g r</kbd> | |
| 99 | + | </dt> | |
| 100 | + | <dd>go commits / refs</dd> | |
| 101 | + | </div> | |
| 102 | + | <div> | |
| 103 | + | <dt> | |
| 104 | + | <kbd>b</kbd> | |
| 105 | + | </dt> | |
| 106 | + | <dd>switch ref (branches, tags)</dd> | |
| 107 | + | </div> | |
| 108 | + | <div> | |
| 109 | + | <dt> | |
| 110 | + | <kbd>:</kbd> | |
| 111 | + | <kbd>f</kbd> | |
| 112 | + | </dt> | |
| 113 | + | <dd>go to: section, ref, file, repo</dd> | |
| 114 | + | </div> | |
| 115 | + | <div> | |
| 116 | + | <dt> | |
| 117 | + | <kbd>y</kbd> | |
| 118 | + | </dt> | |
| 119 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 120 | + | </div> | |
| 121 | + | <div> | |
| 122 | + | <dt> | |
| 123 | + | <kbd>s</kbd> | |
| 124 | + | <kbd>t</kbd> | |
| 125 | + | <kbd>c</kbd> | |
| 126 | + | <kbd>r</kbd> | |
| 127 | + | </dt> | |
| 128 | + | <dd>repo sections (when shown)</dd> | |
| 129 | + | </div> | |
| 130 | + | <div> | |
| 131 | + | <dt> | |
| 132 | + | <kbd>d</kbd> | |
| 133 | + | <kbd>u</kbd> | |
| 134 | + | <kbd>x</kbd> | |
| 135 | + | </dt> | |
| 136 | + | <dd>commit: split / unified / collapse all</dd> | |
| 137 | + | </div> | |
| 138 | + | <div> | |
| 139 | + | <dt> | |
| 140 | + | <kbd>esc</kbd> | |
| 141 | + | </dt> | |
| 142 | + | <dd>close panel</dd> | |
| 143 | + | </div> | |
| 144 | + | </dl> | |
| 145 | + | </dialog> | |
| 146 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 147 | + | <div class="panel-head"> | |
| 148 | + | <label for="cmd-q" class="hi">:</label> | |
| 149 | + | <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(); | |
| 150 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 151 | + | 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(); | |
| 152 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 153 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 154 | + | 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' })) } | |
| 155 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 156 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 157 | + | </div> | |
| 158 | + | <ul id="cmd-list" class="cmd-list"> | |
| 159 | + | <li data-k="go home" data-cursor> | |
| 160 | + | <a href="/" tabindex="-1"> | |
| 161 | + | <span class="k">go</span>home</a> | |
| 162 | + | </li> | |
| 163 | + | <li data-k="repo demo"> | |
| 164 | + | <a href="/demo" tabindex="-1"> | |
| 165 | + | <span class="k">repo</span>demo</a> | |
| 166 | + | </li> | |
| 167 | + | </ul> | |
| 168 | + | </dialog> | |
| 169 | + | <footer class="bar status" aria-label="Status"> | |
| 170 | + | <span class="mode">empty</span> | |
| 171 | + | <span class="st-ctx">empty repository · 0 commits</span> | |
| 172 | + | <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'))"> | |
| 173 | + | <span class="hi">:</span> go to…</button> | |
| 174 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 175 | + | </span> | |
| 176 | + | <span class="st-hints">g h home · ? help</span> | |
| 177 | + | <span class="st-src"> | |
| 178 | + | <span class="st-ver">ermite devel · </span> | |
| 179 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 180 | + | </span> | |
| 181 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 182 | + | </footer> | |
| 183 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 184 | + | if (q('dialog[open]').count) return; | |
| 185 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 186 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 187 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 188 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 189 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 190 | + | })(1); | |
| 191 | + | k-k from:body -> ((d) => { | |
| 192 | + | if (q('dialog[open]').count) return; | |
| 193 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 194 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 195 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 196 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 197 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 198 | + | })(-1); | |
| 199 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 200 | + | 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)); | |
| 201 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 202 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 203 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 204 | + | 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()); | |
| 205 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 206 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 207 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 208 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 209 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 210 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 211 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 212 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 213 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 214 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 215 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 216 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 217 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 218 | + | k-permalink from:body -> { | |
| 219 | + | let b = document.body.dataset; | |
| 220 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 221 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 222 | + | navigator.clipboard?.writeText(location.href); | |
| 223 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 224 | + | }"> | |
| 225 | + | </div> | |
| 226 | + | </body> | |
| 227 | + | </html> | |
| @@ -0,0 +1,227 @@ | |||||
| 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>quiet · 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="" 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="/quiet" aria-current="page">quiet</a> | |||
| 28 | + | </li> | |||
| 29 | + | </ol> | |||
| 30 | + | </nav> | |||
| 31 | + | </header> | |||
| 32 | + | <div class="work"> | |||
| 33 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 34 | + | <section class="state" aria-labelledby="state-title"> | |||
| 35 | + | <h1 id="state-title"> | |||
| 36 | + | <span class="code">empty</span> quiet is empty</h1> | |||
| 37 | + | <p class="dim">no commits yet. push a branch to create one.</p> | |||
| 38 | + | <div class="clone" id="clone-quiet"> | |||
| 39 | + | <div class="clone-line"> | |||
| 40 | + | <span class="mute" aria-hidden="true">$ </span> | |||
| 41 | + | <code>git remote add origin http://example.test/quiet.git</code> | |||
| 42 | + | <button type="button" class="copy" data-label="[copy]" aria-label="copy: git remote add origin http://example.test/quiet.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button> | |||
| 43 | + | </div> | |||
| 44 | + | <div class="clone-line"> | |||
| 45 | + | <span class="mute" aria-hidden="true">$ </span> | |||
| 46 | + | <code>git push -u origin main</code> | |||
| 47 | + | <button type="button" class="copy" data-label="[copy]" aria-label="copy: git push -u origin main" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button> | |||
| 48 | + | </div> | |||
| 49 | + | </div> | |||
| 50 | + | <p class="state-back"> | |||
| 51 | + | <a href="/">home</a> | |||
| 52 | + | </p> | |||
| 53 | + | </section> | |||
| 54 | + | </main> | |||
| 55 | + | </div> | |||
| 56 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 57 | + | <div class="panel-head"> | |||
| 58 | + | <span>keys</span> | |||
| 59 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 60 | + | </div> | |||
| 61 | + | <dl class="keys"> | |||
| 62 | + | <div> | |||
| 63 | + | <dt> | |||
| 64 | + | <kbd>j</kbd> | |||
| 65 | + | <kbd>k</kbd> | |||
| 66 | + | </dt> | |||
| 67 | + | <dd>move cursor down / up</dd> | |||
| 68 | + | </div> | |||
| 69 | + | <div> | |||
| 70 | + | <dt> | |||
| 71 | + | <kbd>enter</kbd> | |||
| 72 | + | </dt> | |||
| 73 | + | <dd>open row under cursor</dd> | |||
| 74 | + | </div> | |||
| 75 | + | <div> | |||
| 76 | + | <dt> | |||
| 77 | + | <kbd>g</kbd> | |||
| 78 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 79 | + | </dt> | |||
| 80 | + | <dd>first / last row</dd> | |||
| 81 | + | </div> | |||
| 82 | + | <div> | |||
| 83 | + | <dt> | |||
| 84 | + | <kbd>/</kbd> | |||
| 85 | + | </dt> | |||
| 86 | + | <dd>focus the filter</dd> | |||
| 87 | + | </div> | |||
| 88 | + | <div> | |||
| 89 | + | <dt> | |||
| 90 | + | <kbd>g h</kbd> | |||
| 91 | + | <kbd>g t</kbd> | |||
| 92 | + | </dt> | |||
| 93 | + | <dd>go home / tree</dd> | |||
| 94 | + | </div> | |||
| 95 | + | <div> | |||
| 96 | + | <dt> | |||
| 97 | + | <kbd>g c</kbd> | |||
| 98 | + | <kbd>g r</kbd> | |||
| 99 | + | </dt> | |||
| 100 | + | <dd>go commits / refs</dd> | |||
| 101 | + | </div> | |||
| 102 | + | <div> | |||
| 103 | + | <dt> | |||
| 104 | + | <kbd>b</kbd> | |||
| 105 | + | </dt> | |||
| 106 | + | <dd>switch ref (branches, tags)</dd> | |||
| 107 | + | </div> | |||
| 108 | + | <div> | |||
| 109 | + | <dt> | |||
| 110 | + | <kbd>:</kbd> | |||
| 111 | + | <kbd>f</kbd> | |||
| 112 | + | </dt> | |||
| 113 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 114 | + | </div> | |||
| 115 | + | <div> | |||
| 116 | + | <dt> | |||
| 117 | + | <kbd>y</kbd> | |||
| 118 | + | </dt> | |||
| 119 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 120 | + | </div> | |||
| 121 | + | <div> | |||
| 122 | + | <dt> | |||
| 123 | + | <kbd>s</kbd> | |||
| 124 | + | <kbd>t</kbd> | |||
| 125 | + | <kbd>c</kbd> | |||
| 126 | + | <kbd>r</kbd> | |||
| 127 | + | </dt> | |||
| 128 | + | <dd>repo sections (when shown)</dd> | |||
| 129 | + | </div> | |||
| 130 | + | <div> | |||
| 131 | + | <dt> | |||
| 132 | + | <kbd>d</kbd> | |||
| 133 | + | <kbd>u</kbd> | |||
| 134 | + | <kbd>x</kbd> | |||
| 135 | + | </dt> | |||
| 136 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 137 | + | </div> | |||
| 138 | + | <div> | |||
| 139 | + | <dt> | |||
| 140 | + | <kbd>esc</kbd> | |||
| 141 | + | </dt> | |||
| 142 | + | <dd>close panel</dd> | |||
| 143 | + | </div> | |||
| 144 | + | </dl> | |||
| 145 | + | </dialog> | |||
| 146 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 147 | + | <div class="panel-head"> | |||
| 148 | + | <label for="cmd-q" class="hi">:</label> | |||
| 149 | + | <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(); | |||
| 150 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 151 | + | 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(); | |||
| 152 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 153 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 154 | + | 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' })) } | |||
| 155 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 156 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 157 | + | </div> | |||
| 158 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 159 | + | <li data-k="go home" data-cursor> | |||
| 160 | + | <a href="/" tabindex="-1"> | |||
| 161 | + | <span class="k">go</span>home</a> | |||
| 162 | + | </li> | |||
| 163 | + | <li data-k="repo demo"> | |||
| 164 | + | <a href="/demo" tabindex="-1"> | |||
| 165 | + | <span class="k">repo</span>demo</a> | |||
| 166 | + | </li> | |||
| 167 | + | </ul> | |||
| 168 | + | </dialog> | |||
| 169 | + | <footer class="bar status" aria-label="Status"> | |||
| 170 | + | <span class="mode">empty</span> | |||
| 171 | + | <span class="st-ctx">empty repository · 0 commits</span> | |||
| 172 | + | <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'))"> | |||
| 173 | + | <span class="hi">:</span> go to…</button> | |||
| 174 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 175 | + | </span> | |||
| 176 | + | <span class="st-hints">g h home · ? help</span> | |||
| 177 | + | <span class="st-src"> | |||
| 178 | + | <span class="st-ver">ermite devel · </span> | |||
| 179 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 180 | + | </span> | |||
| 181 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 182 | + | </footer> | |||
| 183 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 184 | + | if (q('dialog[open]').count) return; | |||
| 185 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 186 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 187 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 188 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 189 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 190 | + | })(1); | |||
| 191 | + | k-k from:body -> ((d) => { | |||
| 192 | + | if (q('dialog[open]').count) return; | |||
| 193 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 194 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 195 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 196 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 197 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 198 | + | })(-1); | |||
| 199 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 200 | + | 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)); | |||
| 201 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 202 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 203 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 204 | + | 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()); | |||
| 205 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 206 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 207 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 208 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 209 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 210 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 211 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 212 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 213 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 214 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 215 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 216 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 217 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 218 | + | k-permalink from:body -> { | |||
| 219 | + | let b = document.body.dataset; | |||
| 220 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 221 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 222 | + | navigator.clipboard?.writeText(location.href); | |||
| 223 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 224 | + | }"> | |||
| 225 | + | </div> | |||
| 226 | + | </body> | |||
| 227 | + | </html> | |||
A cmd/ermite/testdata/web/home.html +268 -0
| @@ -0,0 +1,268 @@ | |||
| 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>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="" 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 | + | </ol> | |
| 27 | + | </nav> | |
| 28 | + | <nav class="secs" aria-label="Sections"> | |
| 29 | + | <ul> | |
| 30 | + | <li> | |
| 31 | + | <a href="/" data-key="h" aria-keyshortcuts="g h" aria-current="page">home</a> | |
| 32 | + | </li> | |
| 33 | + | </ul> | |
| 34 | + | </nav> | |
| 35 | + | </header> | |
| 36 | + | <div class="work"> | |
| 37 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 38 | + | <div class="filter"> | |
| 39 | + | <label for="home-filter">filter></label> | |
| 40 | + | <input id="home-filter" type="search" data-filter placeholder="name or description ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"> | |
| 41 | + | </div> | |
| 42 | + | <table class="list home" aria-label="Repositories"> | |
| 43 | + | <thead> | |
| 44 | + | <tr> | |
| 45 | + | <th class="cur"> | |
| 46 | + | </th> | |
| 47 | + | <th class="c-name" aria-sort="none"> | |
| 48 | + | <a href="/?sort=name&dir=asc">name </a> | |
| 49 | + | </th> | |
| 50 | + | <th class="c-desc" aria-sort="none"> | |
| 51 | + | <a href="/?sort=desc&dir=asc">description </a> | |
| 52 | + | </th> | |
| 53 | + | <th class="c-when" aria-sort="descending"> | |
| 54 | + | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |
| 55 | + | </a> | |
| 56 | + | </th> | |
| 57 | + | <th class="c-ref" aria-sort="none"> | |
| 58 | + | <a href="/?sort=branch&dir=asc">branch </a> | |
| 59 | + | </th> | |
| 60 | + | </tr> | |
| 61 | + | </thead> | |
| 62 | + | <tbody id="repo-rows" data-rows> | |
| 63 | + | <tr id="repo-demo" data-cursor> | |
| 64 | + | <td class="cur" aria-hidden="true"> | |
| 65 | + | </td> | |
| 66 | + | <td class="c-name"> | |
| 67 | + | <a href="/demo">demo</a> | |
| 68 | + | </td> | |
| 69 | + | <td class="c-desc dim">A demo repository</td> | |
| 70 | + | <td class="c-when nowrap"> | |
| 71 | + | <time datetime="STAMP">AGO</time> | |
| 72 | + | </td> | |
| 73 | + | <td class="c-ref">main</td> | |
| 74 | + | </tr> | |
| 75 | + | <tr id="repo-quiet"> | |
| 76 | + | <td class="cur" aria-hidden="true"> | |
| 77 | + | </td> | |
| 78 | + | <td class="c-name"> | |
| 79 | + | <a href="/quiet">quiet</a> | |
| 80 | + | </td> | |
| 81 | + | <td class="c-desc dim"> | |
| 82 | + | </td> | |
| 83 | + | <td class="c-when nowrap"> | |
| 84 | + | <time>never</time> | |
| 85 | + | </td> | |
| 86 | + | <td class="c-ref">main</td> | |
| 87 | + | </tr> | |
| 88 | + | </tbody> | |
| 89 | + | </table> | |
| 90 | + | </main> | |
| 91 | + | </div> | |
| 92 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 93 | + | <div class="panel-head"> | |
| 94 | + | <span>keys</span> | |
| 95 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 96 | + | </div> | |
| 97 | + | <dl class="keys"> | |
| 98 | + | <div> | |
| 99 | + | <dt> | |
| 100 | + | <kbd>j</kbd> | |
| 101 | + | <kbd>k</kbd> | |
| 102 | + | </dt> | |
| 103 | + | <dd>move cursor down / up</dd> | |
| 104 | + | </div> | |
| 105 | + | <div> | |
| 106 | + | <dt> | |
| 107 | + | <kbd>enter</kbd> | |
| 108 | + | </dt> | |
| 109 | + | <dd>open row under cursor</dd> | |
| 110 | + | </div> | |
| 111 | + | <div> | |
| 112 | + | <dt> | |
| 113 | + | <kbd>g</kbd> | |
| 114 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 115 | + | </dt> | |
| 116 | + | <dd>first / last row</dd> | |
| 117 | + | </div> | |
| 118 | + | <div> | |
| 119 | + | <dt> | |
| 120 | + | <kbd>/</kbd> | |
| 121 | + | </dt> | |
| 122 | + | <dd>focus the filter</dd> | |
| 123 | + | </div> | |
| 124 | + | <div> | |
| 125 | + | <dt> | |
| 126 | + | <kbd>g h</kbd> | |
| 127 | + | <kbd>g t</kbd> | |
| 128 | + | </dt> | |
| 129 | + | <dd>go home / tree</dd> | |
| 130 | + | </div> | |
| 131 | + | <div> | |
| 132 | + | <dt> | |
| 133 | + | <kbd>g c</kbd> | |
| 134 | + | <kbd>g r</kbd> | |
| 135 | + | </dt> | |
| 136 | + | <dd>go commits / refs</dd> | |
| 137 | + | </div> | |
| 138 | + | <div> | |
| 139 | + | <dt> | |
| 140 | + | <kbd>b</kbd> | |
| 141 | + | </dt> | |
| 142 | + | <dd>switch ref (branches, tags)</dd> | |
| 143 | + | </div> | |
| 144 | + | <div> | |
| 145 | + | <dt> | |
| 146 | + | <kbd>:</kbd> | |
| 147 | + | <kbd>f</kbd> | |
| 148 | + | </dt> | |
| 149 | + | <dd>go to: section, ref, file, repo</dd> | |
| 150 | + | </div> | |
| 151 | + | <div> | |
| 152 | + | <dt> | |
| 153 | + | <kbd>y</kbd> | |
| 154 | + | </dt> | |
| 155 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 156 | + | </div> | |
| 157 | + | <div> | |
| 158 | + | <dt> | |
| 159 | + | <kbd>s</kbd> | |
| 160 | + | <kbd>t</kbd> | |
| 161 | + | <kbd>c</kbd> | |
| 162 | + | <kbd>r</kbd> | |
| 163 | + | </dt> | |
| 164 | + | <dd>repo sections (when shown)</dd> | |
| 165 | + | </div> | |
| 166 | + | <div> | |
| 167 | + | <dt> | |
| 168 | + | <kbd>d</kbd> | |
| 169 | + | <kbd>u</kbd> | |
| 170 | + | <kbd>x</kbd> | |
| 171 | + | </dt> | |
| 172 | + | <dd>commit: split / unified / collapse all</dd> | |
| 173 | + | </div> | |
| 174 | + | <div> | |
| 175 | + | <dt> | |
| 176 | + | <kbd>esc</kbd> | |
| 177 | + | </dt> | |
| 178 | + | <dd>close panel</dd> | |
| 179 | + | </div> | |
| 180 | + | </dl> | |
| 181 | + | </dialog> | |
| 182 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 183 | + | <div class="panel-head"> | |
| 184 | + | <label for="cmd-q" class="hi">:</label> | |
| 185 | + | <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(); | |
| 186 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 187 | + | 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(); | |
| 188 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 189 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 190 | + | 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' })) } | |
| 191 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 192 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 193 | + | </div> | |
| 194 | + | <ul id="cmd-list" class="cmd-list"> | |
| 195 | + | <li data-k="go home" data-cursor> | |
| 196 | + | <a href="/" tabindex="-1"> | |
| 197 | + | <span class="k">go</span>home</a> | |
| 198 | + | </li> | |
| 199 | + | <li data-k="repo demo"> | |
| 200 | + | <a href="/demo" tabindex="-1"> | |
| 201 | + | <span class="k">repo</span>demo</a> | |
| 202 | + | </li> | |
| 203 | + | <li data-k="repo quiet"> | |
| 204 | + | <a href="/quiet" tabindex="-1"> | |
| 205 | + | <span class="k">repo</span>quiet</a> | |
| 206 | + | </li> | |
| 207 | + | </ul> | |
| 208 | + | </dialog> | |
| 209 | + | <footer class="bar status" aria-label="Status"> | |
| 210 | + | <span class="mode">home</span> | |
| 211 | + | <span class="st-ctx">2 repos · sort push ▼</span> | |
| 212 | + | <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'))"> | |
| 213 | + | <span class="hi">:</span> go to…</button> | |
| 214 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 215 | + | </span> | |
| 216 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |
| 217 | + | <span class="st-src"> | |
| 218 | + | <span class="st-ver">ermite devel · </span> | |
| 219 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 220 | + | </span> | |
| 221 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 222 | + | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | |
| 223 | + | </footer> | |
| 224 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 225 | + | if (q('dialog[open]').count) return; | |
| 226 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 227 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 228 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 229 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 230 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 231 | + | })(1); | |
| 232 | + | k-k from:body -> ((d) => { | |
| 233 | + | if (q('dialog[open]').count) return; | |
| 234 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 235 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 236 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 237 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 238 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 239 | + | })(-1); | |
| 240 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 241 | + | 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)); | |
| 242 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 243 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 244 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 245 | + | 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()); | |
| 246 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 247 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 248 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 249 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 250 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 251 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 252 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 253 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 254 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 255 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 256 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 257 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 258 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 259 | + | k-permalink from:body -> { | |
| 260 | + | let b = document.body.dataset; | |
| 261 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 262 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 263 | + | navigator.clipboard?.writeText(location.href); | |
| 264 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 265 | + | }"> | |
| 266 | + | </div> | |
| 267 | + | </body> | |
| 268 | + | </html> | |
| @@ -0,0 +1,268 @@ | |||||
| 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>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="" 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 | + | </ol> | |||
| 27 | + | </nav> | |||
| 28 | + | <nav class="secs" aria-label="Sections"> | |||
| 29 | + | <ul> | |||
| 30 | + | <li> | |||
| 31 | + | <a href="/" data-key="h" aria-keyshortcuts="g h" aria-current="page">home</a> | |||
| 32 | + | </li> | |||
| 33 | + | </ul> | |||
| 34 | + | </nav> | |||
| 35 | + | </header> | |||
| 36 | + | <div class="work"> | |||
| 37 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 38 | + | <div class="filter"> | |||
| 39 | + | <label for="home-filter">filter></label> | |||
| 40 | + | <input id="home-filter" type="search" data-filter placeholder="name or description ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#repo-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); take('data-cursor', '#repo-rows > tr:not([hidden])')"> | |||
| 41 | + | </div> | |||
| 42 | + | <table class="list home" aria-label="Repositories"> | |||
| 43 | + | <thead> | |||
| 44 | + | <tr> | |||
| 45 | + | <th class="cur"> | |||
| 46 | + | </th> | |||
| 47 | + | <th class="c-name" aria-sort="none"> | |||
| 48 | + | <a href="/?sort=name&dir=asc">name </a> | |||
| 49 | + | </th> | |||
| 50 | + | <th class="c-desc" aria-sort="none"> | |||
| 51 | + | <a href="/?sort=desc&dir=asc">description </a> | |||
| 52 | + | </th> | |||
| 53 | + | <th class="c-when" aria-sort="descending"> | |||
| 54 | + | <a href="/?sort=push&dir=asc">last push <span aria-hidden="true">▼</span> | |||
| 55 | + | </a> | |||
| 56 | + | </th> | |||
| 57 | + | <th class="c-ref" aria-sort="none"> | |||
| 58 | + | <a href="/?sort=branch&dir=asc">branch </a> | |||
| 59 | + | </th> | |||
| 60 | + | </tr> | |||
| 61 | + | </thead> | |||
| 62 | + | <tbody id="repo-rows" data-rows> | |||
| 63 | + | <tr id="repo-demo" data-cursor> | |||
| 64 | + | <td class="cur" aria-hidden="true"> | |||
| 65 | + | </td> | |||
| 66 | + | <td class="c-name"> | |||
| 67 | + | <a href="/demo">demo</a> | |||
| 68 | + | </td> | |||
| 69 | + | <td class="c-desc dim">A demo repository</td> | |||
| 70 | + | <td class="c-when nowrap"> | |||
| 71 | + | <time datetime="STAMP">AGO</time> | |||
| 72 | + | </td> | |||
| 73 | + | <td class="c-ref">main</td> | |||
| 74 | + | </tr> | |||
| 75 | + | <tr id="repo-quiet"> | |||
| 76 | + | <td class="cur" aria-hidden="true"> | |||
| 77 | + | </td> | |||
| 78 | + | <td class="c-name"> | |||
| 79 | + | <a href="/quiet">quiet</a> | |||
| 80 | + | </td> | |||
| 81 | + | <td class="c-desc dim"> | |||
| 82 | + | </td> | |||
| 83 | + | <td class="c-when nowrap"> | |||
| 84 | + | <time>never</time> | |||
| 85 | + | </td> | |||
| 86 | + | <td class="c-ref">main</td> | |||
| 87 | + | </tr> | |||
| 88 | + | </tbody> | |||
| 89 | + | </table> | |||
| 90 | + | </main> | |||
| 91 | + | </div> | |||
| 92 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 93 | + | <div class="panel-head"> | |||
| 94 | + | <span>keys</span> | |||
| 95 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 96 | + | </div> | |||
| 97 | + | <dl class="keys"> | |||
| 98 | + | <div> | |||
| 99 | + | <dt> | |||
| 100 | + | <kbd>j</kbd> | |||
| 101 | + | <kbd>k</kbd> | |||
| 102 | + | </dt> | |||
| 103 | + | <dd>move cursor down / up</dd> | |||
| 104 | + | </div> | |||
| 105 | + | <div> | |||
| 106 | + | <dt> | |||
| 107 | + | <kbd>enter</kbd> | |||
| 108 | + | </dt> | |||
| 109 | + | <dd>open row under cursor</dd> | |||
| 110 | + | </div> | |||
| 111 | + | <div> | |||
| 112 | + | <dt> | |||
| 113 | + | <kbd>g</kbd> | |||
| 114 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 115 | + | </dt> | |||
| 116 | + | <dd>first / last row</dd> | |||
| 117 | + | </div> | |||
| 118 | + | <div> | |||
| 119 | + | <dt> | |||
| 120 | + | <kbd>/</kbd> | |||
| 121 | + | </dt> | |||
| 122 | + | <dd>focus the filter</dd> | |||
| 123 | + | </div> | |||
| 124 | + | <div> | |||
| 125 | + | <dt> | |||
| 126 | + | <kbd>g h</kbd> | |||
| 127 | + | <kbd>g t</kbd> | |||
| 128 | + | </dt> | |||
| 129 | + | <dd>go home / tree</dd> | |||
| 130 | + | </div> | |||
| 131 | + | <div> | |||
| 132 | + | <dt> | |||
| 133 | + | <kbd>g c</kbd> | |||
| 134 | + | <kbd>g r</kbd> | |||
| 135 | + | </dt> | |||
| 136 | + | <dd>go commits / refs</dd> | |||
| 137 | + | </div> | |||
| 138 | + | <div> | |||
| 139 | + | <dt> | |||
| 140 | + | <kbd>b</kbd> | |||
| 141 | + | </dt> | |||
| 142 | + | <dd>switch ref (branches, tags)</dd> | |||
| 143 | + | </div> | |||
| 144 | + | <div> | |||
| 145 | + | <dt> | |||
| 146 | + | <kbd>:</kbd> | |||
| 147 | + | <kbd>f</kbd> | |||
| 148 | + | </dt> | |||
| 149 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 150 | + | </div> | |||
| 151 | + | <div> | |||
| 152 | + | <dt> | |||
| 153 | + | <kbd>y</kbd> | |||
| 154 | + | </dt> | |||
| 155 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 156 | + | </div> | |||
| 157 | + | <div> | |||
| 158 | + | <dt> | |||
| 159 | + | <kbd>s</kbd> | |||
| 160 | + | <kbd>t</kbd> | |||
| 161 | + | <kbd>c</kbd> | |||
| 162 | + | <kbd>r</kbd> | |||
| 163 | + | </dt> | |||
| 164 | + | <dd>repo sections (when shown)</dd> | |||
| 165 | + | </div> | |||
| 166 | + | <div> | |||
| 167 | + | <dt> | |||
| 168 | + | <kbd>d</kbd> | |||
| 169 | + | <kbd>u</kbd> | |||
| 170 | + | <kbd>x</kbd> | |||
| 171 | + | </dt> | |||
| 172 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 173 | + | </div> | |||
| 174 | + | <div> | |||
| 175 | + | <dt> | |||
| 176 | + | <kbd>esc</kbd> | |||
| 177 | + | </dt> | |||
| 178 | + | <dd>close panel</dd> | |||
| 179 | + | </div> | |||
| 180 | + | </dl> | |||
| 181 | + | </dialog> | |||
| 182 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 183 | + | <div class="panel-head"> | |||
| 184 | + | <label for="cmd-q" class="hi">:</label> | |||
| 185 | + | <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(); | |||
| 186 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 187 | + | 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(); | |||
| 188 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 189 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 190 | + | 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' })) } | |||
| 191 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 192 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 193 | + | </div> | |||
| 194 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 195 | + | <li data-k="go home" data-cursor> | |||
| 196 | + | <a href="/" tabindex="-1"> | |||
| 197 | + | <span class="k">go</span>home</a> | |||
| 198 | + | </li> | |||
| 199 | + | <li data-k="repo demo"> | |||
| 200 | + | <a href="/demo" tabindex="-1"> | |||
| 201 | + | <span class="k">repo</span>demo</a> | |||
| 202 | + | </li> | |||
| 203 | + | <li data-k="repo quiet"> | |||
| 204 | + | <a href="/quiet" tabindex="-1"> | |||
| 205 | + | <span class="k">repo</span>quiet</a> | |||
| 206 | + | </li> | |||
| 207 | + | </ul> | |||
| 208 | + | </dialog> | |||
| 209 | + | <footer class="bar status" aria-label="Status"> | |||
| 210 | + | <span class="mode">home</span> | |||
| 211 | + | <span class="st-ctx">2 repos · sort push ▼</span> | |||
| 212 | + | <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'))"> | |||
| 213 | + | <span class="hi">:</span> go to…</button> | |||
| 214 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 215 | + | </span> | |||
| 216 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |||
| 217 | + | <span class="st-src"> | |||
| 218 | + | <span class="st-ver">ermite devel · </span> | |||
| 219 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 220 | + | </span> | |||
| 221 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 222 | + | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | |||
| 223 | + | </footer> | |||
| 224 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 225 | + | if (q('dialog[open]').count) return; | |||
| 226 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 227 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 228 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 229 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 230 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 231 | + | })(1); | |||
| 232 | + | k-k from:body -> ((d) => { | |||
| 233 | + | if (q('dialog[open]').count) return; | |||
| 234 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 235 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 236 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 237 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 238 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 239 | + | })(-1); | |||
| 240 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 241 | + | 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)); | |||
| 242 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 243 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 244 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 245 | + | 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()); | |||
| 246 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 247 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 248 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 249 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 250 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 251 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 252 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 253 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 254 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 255 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 256 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 257 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 258 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 259 | + | k-permalink from:body -> { | |||
| 260 | + | let b = document.body.dataset; | |||
| 261 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 262 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 263 | + | navigator.clipboard?.writeText(location.href); | |||
| 264 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 265 | + | }"> | |||
| 266 | + | </div> | |||
| 267 | + | </body> | |||
| 268 | + | </html> | |||
A cmd/ermite/testdata/web/not-found-page.html +222 -0
| @@ -0,0 +1,222 @@ | |||
| 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>404 · 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="" 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 | + | <span aria-current="page">-</span> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <span aria-current="page">404</span> | |
| 31 | + | </li> | |
| 32 | + | </ol> | |
| 33 | + | </nav> | |
| 34 | + | <nav class="secs" aria-label="Sections"> | |
| 35 | + | <ul> | |
| 36 | + | <li> | |
| 37 | + | <a href="/" data-key="h" aria-keyshortcuts="g h">home</a> | |
| 38 | + | </li> | |
| 39 | + | </ul> | |
| 40 | + | </nav> | |
| 41 | + | </header> | |
| 42 | + | <div class="work"> | |
| 43 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 44 | + | <section class="state" aria-labelledby="state-title"> | |
| 45 | + | <h1 id="state-title"> | |
| 46 | + | <span class="code">404</span> not found</h1> | |
| 47 | + | <p class="dim">no such page, ref or path.</p> | |
| 48 | + | <p class="dim">check the URL, or start from the repo list.</p> | |
| 49 | + | <p class="state-back"> | |
| 50 | + | <a href="/">home</a> | |
| 51 | + | </p> | |
| 52 | + | </section> | |
| 53 | + | </main> | |
| 54 | + | </div> | |
| 55 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 56 | + | <div class="panel-head"> | |
| 57 | + | <span>keys</span> | |
| 58 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 59 | + | </div> | |
| 60 | + | <dl class="keys"> | |
| 61 | + | <div> | |
| 62 | + | <dt> | |
| 63 | + | <kbd>j</kbd> | |
| 64 | + | <kbd>k</kbd> | |
| 65 | + | </dt> | |
| 66 | + | <dd>move cursor down / up</dd> | |
| 67 | + | </div> | |
| 68 | + | <div> | |
| 69 | + | <dt> | |
| 70 | + | <kbd>enter</kbd> | |
| 71 | + | </dt> | |
| 72 | + | <dd>open row under cursor</dd> | |
| 73 | + | </div> | |
| 74 | + | <div> | |
| 75 | + | <dt> | |
| 76 | + | <kbd>g</kbd> | |
| 77 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 78 | + | </dt> | |
| 79 | + | <dd>first / last row</dd> | |
| 80 | + | </div> | |
| 81 | + | <div> | |
| 82 | + | <dt> | |
| 83 | + | <kbd>/</kbd> | |
| 84 | + | </dt> | |
| 85 | + | <dd>focus the filter</dd> | |
| 86 | + | </div> | |
| 87 | + | <div> | |
| 88 | + | <dt> | |
| 89 | + | <kbd>g h</kbd> | |
| 90 | + | <kbd>g t</kbd> | |
| 91 | + | </dt> | |
| 92 | + | <dd>go home / tree</dd> | |
| 93 | + | </div> | |
| 94 | + | <div> | |
| 95 | + | <dt> | |
| 96 | + | <kbd>g c</kbd> | |
| 97 | + | <kbd>g r</kbd> | |
| 98 | + | </dt> | |
| 99 | + | <dd>go commits / refs</dd> | |
| 100 | + | </div> | |
| 101 | + | <div> | |
| 102 | + | <dt> | |
| 103 | + | <kbd>b</kbd> | |
| 104 | + | </dt> | |
| 105 | + | <dd>switch ref (branches, tags)</dd> | |
| 106 | + | </div> | |
| 107 | + | <div> | |
| 108 | + | <dt> | |
| 109 | + | <kbd>:</kbd> | |
| 110 | + | <kbd>f</kbd> | |
| 111 | + | </dt> | |
| 112 | + | <dd>go to: section, ref, file, repo</dd> | |
| 113 | + | </div> | |
| 114 | + | <div> | |
| 115 | + | <dt> | |
| 116 | + | <kbd>y</kbd> | |
| 117 | + | </dt> | |
| 118 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 119 | + | </div> | |
| 120 | + | <div> | |
| 121 | + | <dt> | |
| 122 | + | <kbd>s</kbd> | |
| 123 | + | <kbd>t</kbd> | |
| 124 | + | <kbd>c</kbd> | |
| 125 | + | <kbd>r</kbd> | |
| 126 | + | </dt> | |
| 127 | + | <dd>repo sections (when shown)</dd> | |
| 128 | + | </div> | |
| 129 | + | <div> | |
| 130 | + | <dt> | |
| 131 | + | <kbd>d</kbd> | |
| 132 | + | <kbd>u</kbd> | |
| 133 | + | <kbd>x</kbd> | |
| 134 | + | </dt> | |
| 135 | + | <dd>commit: split / unified / collapse all</dd> | |
| 136 | + | </div> | |
| 137 | + | <div> | |
| 138 | + | <dt> | |
| 139 | + | <kbd>esc</kbd> | |
| 140 | + | </dt> | |
| 141 | + | <dd>close panel</dd> | |
| 142 | + | </div> | |
| 143 | + | </dl> | |
| 144 | + | </dialog> | |
| 145 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 146 | + | <div class="panel-head"> | |
| 147 | + | <label for="cmd-q" class="hi">:</label> | |
| 148 | + | <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(); | |
| 149 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 150 | + | 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(); | |
| 151 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 152 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 153 | + | 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' })) } | |
| 154 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 155 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 156 | + | </div> | |
| 157 | + | <ul id="cmd-list" class="cmd-list"> | |
| 158 | + | <li data-k="go home" data-cursor> | |
| 159 | + | <a href="/" tabindex="-1"> | |
| 160 | + | <span class="k">go</span>home</a> | |
| 161 | + | </li> | |
| 162 | + | </ul> | |
| 163 | + | </dialog> | |
| 164 | + | <footer class="bar status" aria-label="Status"> | |
| 165 | + | <span class="mode">state</span> | |
| 166 | + | <span class="st-ctx">404 · not found</span> | |
| 167 | + | <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'))"> | |
| 168 | + | <span class="hi">:</span> go to…</button> | |
| 169 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 170 | + | </span> | |
| 171 | + | <span class="st-hints">g h home · ? help</span> | |
| 172 | + | <span class="st-src"> | |
| 173 | + | <span class="st-ver">ermite devel · </span> | |
| 174 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 175 | + | </span> | |
| 176 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 177 | + | </footer> | |
| 178 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 179 | + | if (q('dialog[open]').count) return; | |
| 180 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 181 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 182 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 183 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 184 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 185 | + | })(1); | |
| 186 | + | k-k from:body -> ((d) => { | |
| 187 | + | if (q('dialog[open]').count) return; | |
| 188 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 189 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 190 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 191 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 192 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 193 | + | })(-1); | |
| 194 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 195 | + | 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)); | |
| 196 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 197 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 198 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 199 | + | 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()); | |
| 200 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 201 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 202 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 203 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 204 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 205 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 206 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 207 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 208 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 209 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 210 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 211 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 212 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 213 | + | k-permalink from:body -> { | |
| 214 | + | let b = document.body.dataset; | |
| 215 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 216 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 217 | + | navigator.clipboard?.writeText(location.href); | |
| 218 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 219 | + | }"> | |
| 220 | + | </div> | |
| 221 | + | </body> | |
| 222 | + | </html> | |
| @@ -0,0 +1,222 @@ | |||||
| 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>404 · 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="" 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 | + | <span aria-current="page">-</span> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <span aria-current="page">404</span> | |||
| 31 | + | </li> | |||
| 32 | + | </ol> | |||
| 33 | + | </nav> | |||
| 34 | + | <nav class="secs" aria-label="Sections"> | |||
| 35 | + | <ul> | |||
| 36 | + | <li> | |||
| 37 | + | <a href="/" data-key="h" aria-keyshortcuts="g h">home</a> | |||
| 38 | + | </li> | |||
| 39 | + | </ul> | |||
| 40 | + | </nav> | |||
| 41 | + | </header> | |||
| 42 | + | <div class="work"> | |||
| 43 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 44 | + | <section class="state" aria-labelledby="state-title"> | |||
| 45 | + | <h1 id="state-title"> | |||
| 46 | + | <span class="code">404</span> not found</h1> | |||
| 47 | + | <p class="dim">no such page, ref or path.</p> | |||
| 48 | + | <p class="dim">check the URL, or start from the repo list.</p> | |||
| 49 | + | <p class="state-back"> | |||
| 50 | + | <a href="/">home</a> | |||
| 51 | + | </p> | |||
| 52 | + | </section> | |||
| 53 | + | </main> | |||
| 54 | + | </div> | |||
| 55 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 56 | + | <div class="panel-head"> | |||
| 57 | + | <span>keys</span> | |||
| 58 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 59 | + | </div> | |||
| 60 | + | <dl class="keys"> | |||
| 61 | + | <div> | |||
| 62 | + | <dt> | |||
| 63 | + | <kbd>j</kbd> | |||
| 64 | + | <kbd>k</kbd> | |||
| 65 | + | </dt> | |||
| 66 | + | <dd>move cursor down / up</dd> | |||
| 67 | + | </div> | |||
| 68 | + | <div> | |||
| 69 | + | <dt> | |||
| 70 | + | <kbd>enter</kbd> | |||
| 71 | + | </dt> | |||
| 72 | + | <dd>open row under cursor</dd> | |||
| 73 | + | </div> | |||
| 74 | + | <div> | |||
| 75 | + | <dt> | |||
| 76 | + | <kbd>g</kbd> | |||
| 77 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 78 | + | </dt> | |||
| 79 | + | <dd>first / last row</dd> | |||
| 80 | + | </div> | |||
| 81 | + | <div> | |||
| 82 | + | <dt> | |||
| 83 | + | <kbd>/</kbd> | |||
| 84 | + | </dt> | |||
| 85 | + | <dd>focus the filter</dd> | |||
| 86 | + | </div> | |||
| 87 | + | <div> | |||
| 88 | + | <dt> | |||
| 89 | + | <kbd>g h</kbd> | |||
| 90 | + | <kbd>g t</kbd> | |||
| 91 | + | </dt> | |||
| 92 | + | <dd>go home / tree</dd> | |||
| 93 | + | </div> | |||
| 94 | + | <div> | |||
| 95 | + | <dt> | |||
| 96 | + | <kbd>g c</kbd> | |||
| 97 | + | <kbd>g r</kbd> | |||
| 98 | + | </dt> | |||
| 99 | + | <dd>go commits / refs</dd> | |||
| 100 | + | </div> | |||
| 101 | + | <div> | |||
| 102 | + | <dt> | |||
| 103 | + | <kbd>b</kbd> | |||
| 104 | + | </dt> | |||
| 105 | + | <dd>switch ref (branches, tags)</dd> | |||
| 106 | + | </div> | |||
| 107 | + | <div> | |||
| 108 | + | <dt> | |||
| 109 | + | <kbd>:</kbd> | |||
| 110 | + | <kbd>f</kbd> | |||
| 111 | + | </dt> | |||
| 112 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 113 | + | </div> | |||
| 114 | + | <div> | |||
| 115 | + | <dt> | |||
| 116 | + | <kbd>y</kbd> | |||
| 117 | + | </dt> | |||
| 118 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 119 | + | </div> | |||
| 120 | + | <div> | |||
| 121 | + | <dt> | |||
| 122 | + | <kbd>s</kbd> | |||
| 123 | + | <kbd>t</kbd> | |||
| 124 | + | <kbd>c</kbd> | |||
| 125 | + | <kbd>r</kbd> | |||
| 126 | + | </dt> | |||
| 127 | + | <dd>repo sections (when shown)</dd> | |||
| 128 | + | </div> | |||
| 129 | + | <div> | |||
| 130 | + | <dt> | |||
| 131 | + | <kbd>d</kbd> | |||
| 132 | + | <kbd>u</kbd> | |||
| 133 | + | <kbd>x</kbd> | |||
| 134 | + | </dt> | |||
| 135 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 136 | + | </div> | |||
| 137 | + | <div> | |||
| 138 | + | <dt> | |||
| 139 | + | <kbd>esc</kbd> | |||
| 140 | + | </dt> | |||
| 141 | + | <dd>close panel</dd> | |||
| 142 | + | </div> | |||
| 143 | + | </dl> | |||
| 144 | + | </dialog> | |||
| 145 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 146 | + | <div class="panel-head"> | |||
| 147 | + | <label for="cmd-q" class="hi">:</label> | |||
| 148 | + | <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(); | |||
| 149 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 150 | + | 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(); | |||
| 151 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 152 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 153 | + | 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' })) } | |||
| 154 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 155 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 156 | + | </div> | |||
| 157 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 158 | + | <li data-k="go home" data-cursor> | |||
| 159 | + | <a href="/" tabindex="-1"> | |||
| 160 | + | <span class="k">go</span>home</a> | |||
| 161 | + | </li> | |||
| 162 | + | </ul> | |||
| 163 | + | </dialog> | |||
| 164 | + | <footer class="bar status" aria-label="Status"> | |||
| 165 | + | <span class="mode">state</span> | |||
| 166 | + | <span class="st-ctx">404 · not found</span> | |||
| 167 | + | <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'))"> | |||
| 168 | + | <span class="hi">:</span> go to…</button> | |||
| 169 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 170 | + | </span> | |||
| 171 | + | <span class="st-hints">g h home · ? help</span> | |||
| 172 | + | <span class="st-src"> | |||
| 173 | + | <span class="st-ver">ermite devel · </span> | |||
| 174 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 175 | + | </span> | |||
| 176 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 177 | + | </footer> | |||
| 178 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 179 | + | if (q('dialog[open]').count) return; | |||
| 180 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 181 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 182 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 183 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 184 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 185 | + | })(1); | |||
| 186 | + | k-k from:body -> ((d) => { | |||
| 187 | + | if (q('dialog[open]').count) return; | |||
| 188 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 189 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 190 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 191 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 192 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 193 | + | })(-1); | |||
| 194 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 195 | + | 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)); | |||
| 196 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 197 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 198 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 199 | + | 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()); | |||
| 200 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 201 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 202 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 203 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 204 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 205 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 206 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 207 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 208 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 209 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 210 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 211 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 212 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 213 | + | k-permalink from:body -> { | |||
| 214 | + | let b = document.body.dataset; | |||
| 215 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 216 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 217 | + | navigator.clipboard?.writeText(location.href); | |||
| 218 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 219 | + | }"> | |||
| 220 | + | </div> | |||
| 221 | + | </body> | |||
| 222 | + | </html> | |||
A cmd/ermite/testdata/web/not-found.html +222 -0
| @@ -0,0 +1,222 @@ | |||
| 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>404 · 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="" 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 | + | <span aria-current="page">-</span> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <span aria-current="page">404</span> | |
| 31 | + | </li> | |
| 32 | + | </ol> | |
| 33 | + | </nav> | |
| 34 | + | <nav class="secs" aria-label="Sections"> | |
| 35 | + | <ul> | |
| 36 | + | <li> | |
| 37 | + | <a href="/" data-key="h" aria-keyshortcuts="g h">home</a> | |
| 38 | + | </li> | |
| 39 | + | </ul> | |
| 40 | + | </nav> | |
| 41 | + | </header> | |
| 42 | + | <div class="work"> | |
| 43 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 44 | + | <section class="state" aria-labelledby="state-title"> | |
| 45 | + | <h1 id="state-title"> | |
| 46 | + | <span class="code">404</span> not found</h1> | |
| 47 | + | <p class="dim">no such page, ref or path.</p> | |
| 48 | + | <p class="dim">check the URL, or start from the repo list.</p> | |
| 49 | + | <p class="state-back"> | |
| 50 | + | <a href="/">home</a> | |
| 51 | + | </p> | |
| 52 | + | </section> | |
| 53 | + | </main> | |
| 54 | + | </div> | |
| 55 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 56 | + | <div class="panel-head"> | |
| 57 | + | <span>keys</span> | |
| 58 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 59 | + | </div> | |
| 60 | + | <dl class="keys"> | |
| 61 | + | <div> | |
| 62 | + | <dt> | |
| 63 | + | <kbd>j</kbd> | |
| 64 | + | <kbd>k</kbd> | |
| 65 | + | </dt> | |
| 66 | + | <dd>move cursor down / up</dd> | |
| 67 | + | </div> | |
| 68 | + | <div> | |
| 69 | + | <dt> | |
| 70 | + | <kbd>enter</kbd> | |
| 71 | + | </dt> | |
| 72 | + | <dd>open row under cursor</dd> | |
| 73 | + | </div> | |
| 74 | + | <div> | |
| 75 | + | <dt> | |
| 76 | + | <kbd>g</kbd> | |
| 77 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 78 | + | </dt> | |
| 79 | + | <dd>first / last row</dd> | |
| 80 | + | </div> | |
| 81 | + | <div> | |
| 82 | + | <dt> | |
| 83 | + | <kbd>/</kbd> | |
| 84 | + | </dt> | |
| 85 | + | <dd>focus the filter</dd> | |
| 86 | + | </div> | |
| 87 | + | <div> | |
| 88 | + | <dt> | |
| 89 | + | <kbd>g h</kbd> | |
| 90 | + | <kbd>g t</kbd> | |
| 91 | + | </dt> | |
| 92 | + | <dd>go home / tree</dd> | |
| 93 | + | </div> | |
| 94 | + | <div> | |
| 95 | + | <dt> | |
| 96 | + | <kbd>g c</kbd> | |
| 97 | + | <kbd>g r</kbd> | |
| 98 | + | </dt> | |
| 99 | + | <dd>go commits / refs</dd> | |
| 100 | + | </div> | |
| 101 | + | <div> | |
| 102 | + | <dt> | |
| 103 | + | <kbd>b</kbd> | |
| 104 | + | </dt> | |
| 105 | + | <dd>switch ref (branches, tags)</dd> | |
| 106 | + | </div> | |
| 107 | + | <div> | |
| 108 | + | <dt> | |
| 109 | + | <kbd>:</kbd> | |
| 110 | + | <kbd>f</kbd> | |
| 111 | + | </dt> | |
| 112 | + | <dd>go to: section, ref, file, repo</dd> | |
| 113 | + | </div> | |
| 114 | + | <div> | |
| 115 | + | <dt> | |
| 116 | + | <kbd>y</kbd> | |
| 117 | + | </dt> | |
| 118 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 119 | + | </div> | |
| 120 | + | <div> | |
| 121 | + | <dt> | |
| 122 | + | <kbd>s</kbd> | |
| 123 | + | <kbd>t</kbd> | |
| 124 | + | <kbd>c</kbd> | |
| 125 | + | <kbd>r</kbd> | |
| 126 | + | </dt> | |
| 127 | + | <dd>repo sections (when shown)</dd> | |
| 128 | + | </div> | |
| 129 | + | <div> | |
| 130 | + | <dt> | |
| 131 | + | <kbd>d</kbd> | |
| 132 | + | <kbd>u</kbd> | |
| 133 | + | <kbd>x</kbd> | |
| 134 | + | </dt> | |
| 135 | + | <dd>commit: split / unified / collapse all</dd> | |
| 136 | + | </div> | |
| 137 | + | <div> | |
| 138 | + | <dt> | |
| 139 | + | <kbd>esc</kbd> | |
| 140 | + | </dt> | |
| 141 | + | <dd>close panel</dd> | |
| 142 | + | </div> | |
| 143 | + | </dl> | |
| 144 | + | </dialog> | |
| 145 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 146 | + | <div class="panel-head"> | |
| 147 | + | <label for="cmd-q" class="hi">:</label> | |
| 148 | + | <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(); | |
| 149 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 150 | + | 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(); | |
| 151 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 152 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 153 | + | 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' })) } | |
| 154 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 155 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 156 | + | </div> | |
| 157 | + | <ul id="cmd-list" class="cmd-list"> | |
| 158 | + | <li data-k="go home" data-cursor> | |
| 159 | + | <a href="/" tabindex="-1"> | |
| 160 | + | <span class="k">go</span>home</a> | |
| 161 | + | </li> | |
| 162 | + | </ul> | |
| 163 | + | </dialog> | |
| 164 | + | <footer class="bar status" aria-label="Status"> | |
| 165 | + | <span class="mode">state</span> | |
| 166 | + | <span class="st-ctx">404 · not found</span> | |
| 167 | + | <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'))"> | |
| 168 | + | <span class="hi">:</span> go to…</button> | |
| 169 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 170 | + | </span> | |
| 171 | + | <span class="st-hints">g h home · ? help</span> | |
| 172 | + | <span class="st-src"> | |
| 173 | + | <span class="st-ver">ermite devel · </span> | |
| 174 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 175 | + | </span> | |
| 176 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 177 | + | </footer> | |
| 178 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 179 | + | if (q('dialog[open]').count) return; | |
| 180 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 181 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 182 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 183 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 184 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 185 | + | })(1); | |
| 186 | + | k-k from:body -> ((d) => { | |
| 187 | + | if (q('dialog[open]').count) return; | |
| 188 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 189 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 190 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 191 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 192 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 193 | + | })(-1); | |
| 194 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 195 | + | 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)); | |
| 196 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 197 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 198 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 199 | + | 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()); | |
| 200 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 201 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 202 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 203 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 204 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 205 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 206 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 207 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 208 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 209 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 210 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 211 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 212 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 213 | + | k-permalink from:body -> { | |
| 214 | + | let b = document.body.dataset; | |
| 215 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 216 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 217 | + | navigator.clipboard?.writeText(location.href); | |
| 218 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 219 | + | }"> | |
| 220 | + | </div> | |
| 221 | + | </body> | |
| 222 | + | </html> | |
| @@ -0,0 +1,222 @@ | |||||
| 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>404 · 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="" 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 | + | <span aria-current="page">-</span> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <span aria-current="page">404</span> | |||
| 31 | + | </li> | |||
| 32 | + | </ol> | |||
| 33 | + | </nav> | |||
| 34 | + | <nav class="secs" aria-label="Sections"> | |||
| 35 | + | <ul> | |||
| 36 | + | <li> | |||
| 37 | + | <a href="/" data-key="h" aria-keyshortcuts="g h">home</a> | |||
| 38 | + | </li> | |||
| 39 | + | </ul> | |||
| 40 | + | </nav> | |||
| 41 | + | </header> | |||
| 42 | + | <div class="work"> | |||
| 43 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 44 | + | <section class="state" aria-labelledby="state-title"> | |||
| 45 | + | <h1 id="state-title"> | |||
| 46 | + | <span class="code">404</span> not found</h1> | |||
| 47 | + | <p class="dim">no such page, ref or path.</p> | |||
| 48 | + | <p class="dim">check the URL, or start from the repo list.</p> | |||
| 49 | + | <p class="state-back"> | |||
| 50 | + | <a href="/">home</a> | |||
| 51 | + | </p> | |||
| 52 | + | </section> | |||
| 53 | + | </main> | |||
| 54 | + | </div> | |||
| 55 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 56 | + | <div class="panel-head"> | |||
| 57 | + | <span>keys</span> | |||
| 58 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 59 | + | </div> | |||
| 60 | + | <dl class="keys"> | |||
| 61 | + | <div> | |||
| 62 | + | <dt> | |||
| 63 | + | <kbd>j</kbd> | |||
| 64 | + | <kbd>k</kbd> | |||
| 65 | + | </dt> | |||
| 66 | + | <dd>move cursor down / up</dd> | |||
| 67 | + | </div> | |||
| 68 | + | <div> | |||
| 69 | + | <dt> | |||
| 70 | + | <kbd>enter</kbd> | |||
| 71 | + | </dt> | |||
| 72 | + | <dd>open row under cursor</dd> | |||
| 73 | + | </div> | |||
| 74 | + | <div> | |||
| 75 | + | <dt> | |||
| 76 | + | <kbd>g</kbd> | |||
| 77 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 78 | + | </dt> | |||
| 79 | + | <dd>first / last row</dd> | |||
| 80 | + | </div> | |||
| 81 | + | <div> | |||
| 82 | + | <dt> | |||
| 83 | + | <kbd>/</kbd> | |||
| 84 | + | </dt> | |||
| 85 | + | <dd>focus the filter</dd> | |||
| 86 | + | </div> | |||
| 87 | + | <div> | |||
| 88 | + | <dt> | |||
| 89 | + | <kbd>g h</kbd> | |||
| 90 | + | <kbd>g t</kbd> | |||
| 91 | + | </dt> | |||
| 92 | + | <dd>go home / tree</dd> | |||
| 93 | + | </div> | |||
| 94 | + | <div> | |||
| 95 | + | <dt> | |||
| 96 | + | <kbd>g c</kbd> | |||
| 97 | + | <kbd>g r</kbd> | |||
| 98 | + | </dt> | |||
| 99 | + | <dd>go commits / refs</dd> | |||
| 100 | + | </div> | |||
| 101 | + | <div> | |||
| 102 | + | <dt> | |||
| 103 | + | <kbd>b</kbd> | |||
| 104 | + | </dt> | |||
| 105 | + | <dd>switch ref (branches, tags)</dd> | |||
| 106 | + | </div> | |||
| 107 | + | <div> | |||
| 108 | + | <dt> | |||
| 109 | + | <kbd>:</kbd> | |||
| 110 | + | <kbd>f</kbd> | |||
| 111 | + | </dt> | |||
| 112 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 113 | + | </div> | |||
| 114 | + | <div> | |||
| 115 | + | <dt> | |||
| 116 | + | <kbd>y</kbd> | |||
| 117 | + | </dt> | |||
| 118 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 119 | + | </div> | |||
| 120 | + | <div> | |||
| 121 | + | <dt> | |||
| 122 | + | <kbd>s</kbd> | |||
| 123 | + | <kbd>t</kbd> | |||
| 124 | + | <kbd>c</kbd> | |||
| 125 | + | <kbd>r</kbd> | |||
| 126 | + | </dt> | |||
| 127 | + | <dd>repo sections (when shown)</dd> | |||
| 128 | + | </div> | |||
| 129 | + | <div> | |||
| 130 | + | <dt> | |||
| 131 | + | <kbd>d</kbd> | |||
| 132 | + | <kbd>u</kbd> | |||
| 133 | + | <kbd>x</kbd> | |||
| 134 | + | </dt> | |||
| 135 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 136 | + | </div> | |||
| 137 | + | <div> | |||
| 138 | + | <dt> | |||
| 139 | + | <kbd>esc</kbd> | |||
| 140 | + | </dt> | |||
| 141 | + | <dd>close panel</dd> | |||
| 142 | + | </div> | |||
| 143 | + | </dl> | |||
| 144 | + | </dialog> | |||
| 145 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 146 | + | <div class="panel-head"> | |||
| 147 | + | <label for="cmd-q" class="hi">:</label> | |||
| 148 | + | <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(); | |||
| 149 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 150 | + | 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(); | |||
| 151 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 152 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 153 | + | 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' })) } | |||
| 154 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 155 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 156 | + | </div> | |||
| 157 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 158 | + | <li data-k="go home" data-cursor> | |||
| 159 | + | <a href="/" tabindex="-1"> | |||
| 160 | + | <span class="k">go</span>home</a> | |||
| 161 | + | </li> | |||
| 162 | + | </ul> | |||
| 163 | + | </dialog> | |||
| 164 | + | <footer class="bar status" aria-label="Status"> | |||
| 165 | + | <span class="mode">state</span> | |||
| 166 | + | <span class="st-ctx">404 · not found</span> | |||
| 167 | + | <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'))"> | |||
| 168 | + | <span class="hi">:</span> go to…</button> | |||
| 169 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 170 | + | </span> | |||
| 171 | + | <span class="st-hints">g h home · ? help</span> | |||
| 172 | + | <span class="st-src"> | |||
| 173 | + | <span class="st-ver">ermite devel · </span> | |||
| 174 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 175 | + | </span> | |||
| 176 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 177 | + | </footer> | |||
| 178 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 179 | + | if (q('dialog[open]').count) return; | |||
| 180 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 181 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 182 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 183 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 184 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 185 | + | })(1); | |||
| 186 | + | k-k from:body -> ((d) => { | |||
| 187 | + | if (q('dialog[open]').count) return; | |||
| 188 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 189 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 190 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 191 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 192 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 193 | + | })(-1); | |||
| 194 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 195 | + | 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)); | |||
| 196 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 197 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 198 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 199 | + | 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()); | |||
| 200 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 201 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 202 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 203 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 204 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 205 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 206 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 207 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 208 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 209 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 210 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 211 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 212 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 213 | + | k-permalink from:body -> { | |||
| 214 | + | let b = document.body.dataset; | |||
| 215 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 216 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 217 | + | navigator.clipboard?.writeText(location.href); | |||
| 218 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 219 | + | }"> | |||
| 220 | + | </div> | |||
| 221 | + | </body> | |||
| 222 | + | </html> | |||
A cmd/ermite/testdata/web/refs.html +404 -0
| @@ -0,0 +1,404 @@ | |||
| 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>refs · demo · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/demo">demo</a> | |
| 28 | + | </li> | |
| 29 | + | <li> | |
| 30 | + | <span aria-current="page">refs</span> | |
| 31 | + | </li> | |
| 32 | + | </ol> | |
| 33 | + | </nav> | |
| 34 | + | <nav class="secs" aria-label="Sections"> | |
| 35 | + | <ul> | |
| 36 | + | <li> | |
| 37 | + | <a href="/demo" data-key="s" aria-keyshortcuts="g s">summary</a> | |
| 38 | + | </li> | |
| 39 | + | <li> | |
| 40 | + | <a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 44 | + | </li> | |
| 45 | + | <li> | |
| 46 | + | <a href="/demo/refs" data-key="r" aria-keyshortcuts="g r" aria-current="page">refs</a> | |
| 47 | + | </li> | |
| 48 | + | </ul> | |
| 49 | + | </nav> | |
| 50 | + | <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> | |
| 51 | + | </button> | |
| 52 | + | </header> | |
| 53 | + | <div class="work has-rail"> | |
| 54 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 55 | + | <div class="filter"> | |
| 56 | + | <label for="ref-filter">filter></label> | |
| 57 | + | <input id="ref-filter" type="search" data-filter placeholder="branch or tag ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |
| 58 | + | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"> | |
| 59 | + | </div> | |
| 60 | + | <table class="list refs" aria-label="Branches and tags"> | |
| 61 | + | <thead> | |
| 62 | + | <tr> | |
| 63 | + | <th class="cur"> | |
| 64 | + | </th> | |
| 65 | + | <th class="c-kind">kind</th> | |
| 66 | + | <th class="c-name">name</th> | |
| 67 | + | <th class="c-sha">commit</th> | |
| 68 | + | <th class="c-sub">subject</th> | |
| 69 | + | <th class="c-when">date</th> | |
| 70 | + | </tr> | |
| 71 | + | </thead> | |
| 72 | + | <tbody id="ref-rows" data-rows> | |
| 73 | + | <tr data-cursor> | |
| 74 | + | <td class="cur" aria-hidden="true"> | |
| 75 | + | </td> | |
| 76 | + | <td class="c-kind mute">branch</td> | |
| 77 | + | <td class="c-name"> | |
| 78 | + | <a href="/demo/tree/main">main</a> | |
| 79 | + | <span class="tagged">*</span> | |
| 80 | + | </td> | |
| 81 | + | <td class="c-sha"> | |
| 82 | + | <a href="/demo/commit/SHA40">SHA7</a> | |
| 83 | + | </td> | |
| 84 | + | <td class="c-sub">add main.go</td> | |
| 85 | + | <td class="c-when dim"> | |
| 86 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 87 | + | </td> | |
| 88 | + | </tr> | |
| 89 | + | <tr> | |
| 90 | + | <td class="cur" aria-hidden="true"> | |
| 91 | + | </td> | |
| 92 | + | <td class="c-kind mute">branch</td> | |
| 93 | + | <td class="c-name"> | |
| 94 | + | <a href="/demo/tree/feature/x">feature/x</a> | |
| 95 | + | </td> | |
| 96 | + | <td class="c-sha"> | |
| 97 | + | <a href="/demo/commit/SHA40">SHA7</a> | |
| 98 | + | </td> | |
| 99 | + | <td class="c-sub">work on x</td> | |
| 100 | + | <td class="c-when dim"> | |
| 101 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 102 | + | </td> | |
| 103 | + | </tr> | |
| 104 | + | <tr> | |
| 105 | + | <td class="cur" aria-hidden="true"> | |
| 106 | + | </td> | |
| 107 | + | <td class="c-kind mute">tag</td> | |
| 108 | + | <td class="c-name"> | |
| 109 | + | <a href="/demo/tree/v0.2.0">v0.2.0</a> | |
| 110 | + | </td> | |
| 111 | + | <td class="c-sha"> | |
| 112 | + | <a href="/demo/commit/SHA40">SHA7</a> | |
| 113 | + | </td> | |
| 114 | + | <td class="c-sub">add main.go</td> | |
| 115 | + | <td class="c-when dim"> | |
| 116 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 117 | + | </td> | |
| 118 | + | </tr> | |
| 119 | + | <tr> | |
| 120 | + | <td class="cur" aria-hidden="true"> | |
| 121 | + | </td> | |
| 122 | + | <td class="c-kind mute">tag</td> | |
| 123 | + | <td class="c-name"> | |
| 124 | + | <a href="/demo/tree/v0.1.0">v0.1.0</a> | |
| 125 | + | </td> | |
| 126 | + | <td class="c-sha"> | |
| 127 | + | <a href="/demo/commit/SHA40">SHA7</a> | |
| 128 | + | </td> | |
| 129 | + | <td class="c-sub">first commit</td> | |
| 130 | + | <td class="c-when dim"> | |
| 131 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 132 | + | </td> | |
| 133 | + | </tr> | |
| 134 | + | </tbody> | |
| 135 | + | </table> | |
| 136 | + | </main> | |
| 137 | + | <aside class="rail" aria-label="Context"> | |
| 138 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 139 | + | <summary>refs · legend</summary> | |
| 140 | + | <section aria-labelledby="r-refs"> | |
| 141 | + | <h2 id="r-refs">refs</h2> | |
| 142 | + | <dl class="kv"> | |
| 143 | + | <dt>default</dt> | |
| 144 | + | <dd>main</dd> | |
| 145 | + | <dt>branches</dt> | |
| 146 | + | <dd>2</dd> | |
| 147 | + | <dt>tags</dt> | |
| 148 | + | <dd>2</dd> | |
| 149 | + | <dt>latest tag</dt> | |
| 150 | + | <dd>v0.2.0</dd> | |
| 151 | + | </dl> | |
| 152 | + | </section> | |
| 153 | + | <section aria-labelledby="r-legend"> | |
| 154 | + | <h2 id="r-legend">legend</h2> | |
| 155 | + | <dl class="kv"> | |
| 156 | + | <dt>*</dt> | |
| 157 | + | <dd>default branch</dd> | |
| 158 | + | </dl> | |
| 159 | + | </section> | |
| 160 | + | </details> | |
| 161 | + | </aside> | |
| 162 | + | </div> | |
| 163 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 164 | + | <div class="panel-head"> | |
| 165 | + | <span>keys</span> | |
| 166 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 167 | + | </div> | |
| 168 | + | <dl class="keys"> | |
| 169 | + | <div> | |
| 170 | + | <dt> | |
| 171 | + | <kbd>j</kbd> | |
| 172 | + | <kbd>k</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>move cursor down / up</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>enter</kbd> | |
| 179 | + | </dt> | |
| 180 | + | <dd>open row under cursor</dd> | |
| 181 | + | </div> | |
| 182 | + | <div> | |
| 183 | + | <dt> | |
| 184 | + | <kbd>g</kbd> | |
| 185 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 186 | + | </dt> | |
| 187 | + | <dd>first / last row</dd> | |
| 188 | + | </div> | |
| 189 | + | <div> | |
| 190 | + | <dt> | |
| 191 | + | <kbd>/</kbd> | |
| 192 | + | </dt> | |
| 193 | + | <dd>focus the filter</dd> | |
| 194 | + | </div> | |
| 195 | + | <div> | |
| 196 | + | <dt> | |
| 197 | + | <kbd>g h</kbd> | |
| 198 | + | <kbd>g t</kbd> | |
| 199 | + | </dt> | |
| 200 | + | <dd>go home / tree</dd> | |
| 201 | + | </div> | |
| 202 | + | <div> | |
| 203 | + | <dt> | |
| 204 | + | <kbd>g c</kbd> | |
| 205 | + | <kbd>g r</kbd> | |
| 206 | + | </dt> | |
| 207 | + | <dd>go commits / refs</dd> | |
| 208 | + | </div> | |
| 209 | + | <div> | |
| 210 | + | <dt> | |
| 211 | + | <kbd>b</kbd> | |
| 212 | + | </dt> | |
| 213 | + | <dd>switch ref (branches, tags)</dd> | |
| 214 | + | </div> | |
| 215 | + | <div> | |
| 216 | + | <dt> | |
| 217 | + | <kbd>:</kbd> | |
| 218 | + | <kbd>f</kbd> | |
| 219 | + | </dt> | |
| 220 | + | <dd>go to: section, ref, file, repo</dd> | |
| 221 | + | </div> | |
| 222 | + | <div> | |
| 223 | + | <dt> | |
| 224 | + | <kbd>y</kbd> | |
| 225 | + | </dt> | |
| 226 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 227 | + | </div> | |
| 228 | + | <div> | |
| 229 | + | <dt> | |
| 230 | + | <kbd>s</kbd> | |
| 231 | + | <kbd>t</kbd> | |
| 232 | + | <kbd>c</kbd> | |
| 233 | + | <kbd>r</kbd> | |
| 234 | + | </dt> | |
| 235 | + | <dd>repo sections (when shown)</dd> | |
| 236 | + | </div> | |
| 237 | + | <div> | |
| 238 | + | <dt> | |
| 239 | + | <kbd>d</kbd> | |
| 240 | + | <kbd>u</kbd> | |
| 241 | + | <kbd>x</kbd> | |
| 242 | + | </dt> | |
| 243 | + | <dd>commit: split / unified / collapse all</dd> | |
| 244 | + | </div> | |
| 245 | + | <div> | |
| 246 | + | <dt> | |
| 247 | + | <kbd>esc</kbd> | |
| 248 | + | </dt> | |
| 249 | + | <dd>close panel</dd> | |
| 250 | + | </div> | |
| 251 | + | </dl> | |
| 252 | + | </dialog> | |
| 253 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 254 | + | <div class="panel-head"> | |
| 255 | + | <label for="refs-filter">ref></label> | |
| 256 | + | <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))"> | |
| 257 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 258 | + | </div> | |
| 259 | + | <div class="panel-cols" data-picklist> | |
| 260 | + | <section aria-label="Branches"> | |
| 261 | + | <h2>branches <span class="mute">2</span> | |
| 262 | + | </h2> | |
| 263 | + | <ul> | |
| 264 | + | <li> | |
| 265 | + | <a href="/demo/tree/feature/x"> | |
| 266 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 267 | + | </li> | |
| 268 | + | <li> | |
| 269 | + | <a href="/demo/tree/main" aria-current="true"> | |
| 270 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 271 | + | </li> | |
| 272 | + | </ul> | |
| 273 | + | </section> | |
| 274 | + | <section aria-label="Tags"> | |
| 275 | + | <h2>tags <span class="mute">2</span> | |
| 276 | + | </h2> | |
| 277 | + | <ul> | |
| 278 | + | <li> | |
| 279 | + | <a href="/demo/tree/v0.1.0"> | |
| 280 | + | <span class="mark" aria-hidden="true"> </span>v0.1.0</a> | |
| 281 | + | </li> | |
| 282 | + | <li> | |
| 283 | + | <a href="/demo/tree/v0.2.0"> | |
| 284 | + | <span class="mark" aria-hidden="true"> </span>v0.2.0</a> | |
| 285 | + | </li> | |
| 286 | + | </ul> | |
| 287 | + | </section> | |
| 288 | + | </div> | |
| 289 | + | </dialog> | |
| 290 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 291 | + | <div class="panel-head"> | |
| 292 | + | <label for="cmd-q" class="hi">:</label> | |
| 293 | + | <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(); | |
| 294 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 295 | + | 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(); | |
| 296 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 297 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 298 | + | 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' })) } | |
| 299 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 300 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 301 | + | </div> | |
| 302 | + | <ul id="cmd-list" class="cmd-list"> | |
| 303 | + | <li data-k="go home" data-cursor> | |
| 304 | + | <a href="/" tabindex="-1"> | |
| 305 | + | <span class="k">go</span>home</a> | |
| 306 | + | </li> | |
| 307 | + | <li data-k="section summary"> | |
| 308 | + | <a href="/demo" tabindex="-1"> | |
| 309 | + | <span class="k">section</span>summary</a> | |
| 310 | + | </li> | |
| 311 | + | <li data-k="section tree"> | |
| 312 | + | <a href="/demo/tree/main" tabindex="-1"> | |
| 313 | + | <span class="k">section</span>tree</a> | |
| 314 | + | </li> | |
| 315 | + | <li data-k="section commits"> | |
| 316 | + | <a href="/demo/commits/main" tabindex="-1"> | |
| 317 | + | <span class="k">section</span>commits</a> | |
| 318 | + | </li> | |
| 319 | + | <li data-k="section refs"> | |
| 320 | + | <a href="/demo/refs" tabindex="-1"> | |
| 321 | + | <span class="k">section</span>refs</a> | |
| 322 | + | </li> | |
| 323 | + | <li data-k="branch feature/x"> | |
| 324 | + | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 325 | + | <span class="k">branch</span>feature/x</a> | |
| 326 | + | </li> | |
| 327 | + | <li data-k="branch main"> | |
| 328 | + | <a href="/demo/tree/main" tabindex="-1"> | |
| 329 | + | <span class="k">branch</span>main</a> | |
| 330 | + | </li> | |
| 331 | + | <li data-k="tag v0.1.0"> | |
| 332 | + | <a href="/demo/tree/v0.1.0" tabindex="-1"> | |
| 333 | + | <span class="k">tag</span>v0.1.0</a> | |
| 334 | + | </li> | |
| 335 | + | <li data-k="tag v0.2.0"> | |
| 336 | + | <a href="/demo/tree/v0.2.0" tabindex="-1"> | |
| 337 | + | <span class="k">tag</span>v0.2.0</a> | |
| 338 | + | </li> | |
| 339 | + | <li data-k="repo quiet"> | |
| 340 | + | <a href="/quiet" tabindex="-1"> | |
| 341 | + | <span class="k">repo</span>quiet</a> | |
| 342 | + | </li> | |
| 343 | + | </ul> | |
| 344 | + | </dialog> | |
| 345 | + | <footer class="bar status" aria-label="Status"> | |
| 346 | + | <span class="mode">refs</span> | |
| 347 | + | <span class="st-ctx">2 branches · 2 tags</span> | |
| 348 | + | <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'))"> | |
| 349 | + | <span class="hi">:</span> go to…</button> | |
| 350 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 351 | + | </span> | |
| 352 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |
| 353 | + | <span class="st-src"> | |
| 354 | + | <span class="st-ver">ermite devel · </span> | |
| 355 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 356 | + | </span> | |
| 357 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 358 | + | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span> | |
| 359 | + | </footer> | |
| 360 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 361 | + | if (q('dialog[open]').count) return; | |
| 362 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 363 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 364 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 365 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 366 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 367 | + | })(1); | |
| 368 | + | k-k from:body -> ((d) => { | |
| 369 | + | if (q('dialog[open]').count) return; | |
| 370 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 371 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 372 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 373 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 374 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 375 | + | })(-1); | |
| 376 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 377 | + | 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)); | |
| 378 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 379 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 380 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 381 | + | 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()); | |
| 382 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 383 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 384 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 385 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 386 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 387 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 388 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 389 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 390 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 391 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 392 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 393 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 394 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 395 | + | k-permalink from:body -> { | |
| 396 | + | let b = document.body.dataset; | |
| 397 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 398 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 399 | + | navigator.clipboard?.writeText(location.href); | |
| 400 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 401 | + | }"> | |
| 402 | + | </div> | |
| 403 | + | </body> | |
| 404 | + | </html> | |
| @@ -0,0 +1,404 @@ | |||||
| 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>refs · demo · example.test</title> | |||
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 10 | + | </script> | |||
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 12 | + | </script> | |||
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 14 | + | </script> | |||
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 16 | + | </script> | |||
| 17 | + | </head> | |||
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |||
| 19 | + | <a class="skip" href="#main">skip to content</a> | |||
| 20 | + | <header class="bar top"> | |||
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |||
| 22 | + | <span class="fade" aria-hidden="true"> | |||
| 23 | + | </span> | |||
| 24 | + | <nav class="crumbs" aria-label="Path"> | |||
| 25 | + | <ol> | |||
| 26 | + | <li> | |||
| 27 | + | <a href="/demo">demo</a> | |||
| 28 | + | </li> | |||
| 29 | + | <li> | |||
| 30 | + | <span aria-current="page">refs</span> | |||
| 31 | + | </li> | |||
| 32 | + | </ol> | |||
| 33 | + | </nav> | |||
| 34 | + | <nav class="secs" aria-label="Sections"> | |||
| 35 | + | <ul> | |||
| 36 | + | <li> | |||
| 37 | + | <a href="/demo" data-key="s" aria-keyshortcuts="g s">summary</a> | |||
| 38 | + | </li> | |||
| 39 | + | <li> | |||
| 40 | + | <a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |||
| 41 | + | </li> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |||
| 44 | + | </li> | |||
| 45 | + | <li> | |||
| 46 | + | <a href="/demo/refs" data-key="r" aria-keyshortcuts="g r" aria-current="page">refs</a> | |||
| 47 | + | </li> | |||
| 48 | + | </ul> | |||
| 49 | + | </nav> | |||
| 50 | + | <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> | |||
| 51 | + | </button> | |||
| 52 | + | </header> | |||
| 53 | + | <div class="work has-rail"> | |||
| 54 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 55 | + | <div class="filter"> | |||
| 56 | + | <label for="ref-filter">filter></label> | |||
| 57 | + | <input id="ref-filter" type="search" data-filter placeholder="branch or tag ( / )" autocomplete="off" hx-on:input="let t = this.value.toLowerCase(); q('#ref-rows > tr').arr().forEach(r => r.hidden = !r.textContent.toLowerCase().includes(t)); | |||
| 58 | + | let first = q('#ref-rows > tr:not([hidden])').arr()[0]; first && q(first).take('data-cursor', '#ref-rows > tr')"> | |||
| 59 | + | </div> | |||
| 60 | + | <table class="list refs" aria-label="Branches and tags"> | |||
| 61 | + | <thead> | |||
| 62 | + | <tr> | |||
| 63 | + | <th class="cur"> | |||
| 64 | + | </th> | |||
| 65 | + | <th class="c-kind">kind</th> | |||
| 66 | + | <th class="c-name">name</th> | |||
| 67 | + | <th class="c-sha">commit</th> | |||
| 68 | + | <th class="c-sub">subject</th> | |||
| 69 | + | <th class="c-when">date</th> | |||
| 70 | + | </tr> | |||
| 71 | + | </thead> | |||
| 72 | + | <tbody id="ref-rows" data-rows> | |||
| 73 | + | <tr data-cursor> | |||
| 74 | + | <td class="cur" aria-hidden="true"> | |||
| 75 | + | </td> | |||
| 76 | + | <td class="c-kind mute">branch</td> | |||
| 77 | + | <td class="c-name"> | |||
| 78 | + | <a href="/demo/tree/main">main</a> | |||
| 79 | + | <span class="tagged">*</span> | |||
| 80 | + | </td> | |||
| 81 | + | <td class="c-sha"> | |||
| 82 | + | <a href="/demo/commit/SHA40">SHA7</a> | |||
| 83 | + | </td> | |||
| 84 | + | <td class="c-sub">add main.go</td> | |||
| 85 | + | <td class="c-when dim"> | |||
| 86 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 87 | + | </td> | |||
| 88 | + | </tr> | |||
| 89 | + | <tr> | |||
| 90 | + | <td class="cur" aria-hidden="true"> | |||
| 91 | + | </td> | |||
| 92 | + | <td class="c-kind mute">branch</td> | |||
| 93 | + | <td class="c-name"> | |||
| 94 | + | <a href="/demo/tree/feature/x">feature/x</a> | |||
| 95 | + | </td> | |||
| 96 | + | <td class="c-sha"> | |||
| 97 | + | <a href="/demo/commit/SHA40">SHA7</a> | |||
| 98 | + | </td> | |||
| 99 | + | <td class="c-sub">work on x</td> | |||
| 100 | + | <td class="c-when dim"> | |||
| 101 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 102 | + | </td> | |||
| 103 | + | </tr> | |||
| 104 | + | <tr> | |||
| 105 | + | <td class="cur" aria-hidden="true"> | |||
| 106 | + | </td> | |||
| 107 | + | <td class="c-kind mute">tag</td> | |||
| 108 | + | <td class="c-name"> | |||
| 109 | + | <a href="/demo/tree/v0.2.0">v0.2.0</a> | |||
| 110 | + | </td> | |||
| 111 | + | <td class="c-sha"> | |||
| 112 | + | <a href="/demo/commit/SHA40">SHA7</a> | |||
| 113 | + | </td> | |||
| 114 | + | <td class="c-sub">add main.go</td> | |||
| 115 | + | <td class="c-when dim"> | |||
| 116 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 117 | + | </td> | |||
| 118 | + | </tr> | |||
| 119 | + | <tr> | |||
| 120 | + | <td class="cur" aria-hidden="true"> | |||
| 121 | + | </td> | |||
| 122 | + | <td class="c-kind mute">tag</td> | |||
| 123 | + | <td class="c-name"> | |||
| 124 | + | <a href="/demo/tree/v0.1.0">v0.1.0</a> | |||
| 125 | + | </td> | |||
| 126 | + | <td class="c-sha"> | |||
| 127 | + | <a href="/demo/commit/SHA40">SHA7</a> | |||
| 128 | + | </td> | |||
| 129 | + | <td class="c-sub">first commit</td> | |||
| 130 | + | <td class="c-when dim"> | |||
| 131 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 132 | + | </td> | |||
| 133 | + | </tr> | |||
| 134 | + | </tbody> | |||
| 135 | + | </table> | |||
| 136 | + | </main> | |||
| 137 | + | <aside class="rail" aria-label="Context"> | |||
| 138 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 139 | + | <summary>refs · legend</summary> | |||
| 140 | + | <section aria-labelledby="r-refs"> | |||
| 141 | + | <h2 id="r-refs">refs</h2> | |||
| 142 | + | <dl class="kv"> | |||
| 143 | + | <dt>default</dt> | |||
| 144 | + | <dd>main</dd> | |||
| 145 | + | <dt>branches</dt> | |||
| 146 | + | <dd>2</dd> | |||
| 147 | + | <dt>tags</dt> | |||
| 148 | + | <dd>2</dd> | |||
| 149 | + | <dt>latest tag</dt> | |||
| 150 | + | <dd>v0.2.0</dd> | |||
| 151 | + | </dl> | |||
| 152 | + | </section> | |||
| 153 | + | <section aria-labelledby="r-legend"> | |||
| 154 | + | <h2 id="r-legend">legend</h2> | |||
| 155 | + | <dl class="kv"> | |||
| 156 | + | <dt>*</dt> | |||
| 157 | + | <dd>default branch</dd> | |||
| 158 | + | </dl> | |||
| 159 | + | </section> | |||
| 160 | + | </details> | |||
| 161 | + | </aside> | |||
| 162 | + | </div> | |||
| 163 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 164 | + | <div class="panel-head"> | |||
| 165 | + | <span>keys</span> | |||
| 166 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 167 | + | </div> | |||
| 168 | + | <dl class="keys"> | |||
| 169 | + | <div> | |||
| 170 | + | <dt> | |||
| 171 | + | <kbd>j</kbd> | |||
| 172 | + | <kbd>k</kbd> | |||
| 173 | + | </dt> | |||
| 174 | + | <dd>move cursor down / up</dd> | |||
| 175 | + | </div> | |||
| 176 | + | <div> | |||
| 177 | + | <dt> | |||
| 178 | + | <kbd>enter</kbd> | |||
| 179 | + | </dt> | |||
| 180 | + | <dd>open row under cursor</dd> | |||
| 181 | + | </div> | |||
| 182 | + | <div> | |||
| 183 | + | <dt> | |||
| 184 | + | <kbd>g</kbd> | |||
| 185 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 186 | + | </dt> | |||
| 187 | + | <dd>first / last row</dd> | |||
| 188 | + | </div> | |||
| 189 | + | <div> | |||
| 190 | + | <dt> | |||
| 191 | + | <kbd>/</kbd> | |||
| 192 | + | </dt> | |||
| 193 | + | <dd>focus the filter</dd> | |||
| 194 | + | </div> | |||
| 195 | + | <div> | |||
| 196 | + | <dt> | |||
| 197 | + | <kbd>g h</kbd> | |||
| 198 | + | <kbd>g t</kbd> | |||
| 199 | + | </dt> | |||
| 200 | + | <dd>go home / tree</dd> | |||
| 201 | + | </div> | |||
| 202 | + | <div> | |||
| 203 | + | <dt> | |||
| 204 | + | <kbd>g c</kbd> | |||
| 205 | + | <kbd>g r</kbd> | |||
| 206 | + | </dt> | |||
| 207 | + | <dd>go commits / refs</dd> | |||
| 208 | + | </div> | |||
| 209 | + | <div> | |||
| 210 | + | <dt> | |||
| 211 | + | <kbd>b</kbd> | |||
| 212 | + | </dt> | |||
| 213 | + | <dd>switch ref (branches, tags)</dd> | |||
| 214 | + | </div> | |||
| 215 | + | <div> | |||
| 216 | + | <dt> | |||
| 217 | + | <kbd>:</kbd> | |||
| 218 | + | <kbd>f</kbd> | |||
| 219 | + | </dt> | |||
| 220 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 221 | + | </div> | |||
| 222 | + | <div> | |||
| 223 | + | <dt> | |||
| 224 | + | <kbd>y</kbd> | |||
| 225 | + | </dt> | |||
| 226 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 227 | + | </div> | |||
| 228 | + | <div> | |||
| 229 | + | <dt> | |||
| 230 | + | <kbd>s</kbd> | |||
| 231 | + | <kbd>t</kbd> | |||
| 232 | + | <kbd>c</kbd> | |||
| 233 | + | <kbd>r</kbd> | |||
| 234 | + | </dt> | |||
| 235 | + | <dd>repo sections (when shown)</dd> | |||
| 236 | + | </div> | |||
| 237 | + | <div> | |||
| 238 | + | <dt> | |||
| 239 | + | <kbd>d</kbd> | |||
| 240 | + | <kbd>u</kbd> | |||
| 241 | + | <kbd>x</kbd> | |||
| 242 | + | </dt> | |||
| 243 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 244 | + | </div> | |||
| 245 | + | <div> | |||
| 246 | + | <dt> | |||
| 247 | + | <kbd>esc</kbd> | |||
| 248 | + | </dt> | |||
| 249 | + | <dd>close panel</dd> | |||
| 250 | + | </div> | |||
| 251 | + | </dl> | |||
| 252 | + | </dialog> | |||
| 253 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 254 | + | <div class="panel-head"> | |||
| 255 | + | <label for="refs-filter">ref></label> | |||
| 256 | + | <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))"> | |||
| 257 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 258 | + | </div> | |||
| 259 | + | <div class="panel-cols" data-picklist> | |||
| 260 | + | <section aria-label="Branches"> | |||
| 261 | + | <h2>branches <span class="mute">2</span> | |||
| 262 | + | </h2> | |||
| 263 | + | <ul> | |||
| 264 | + | <li> | |||
| 265 | + | <a href="/demo/tree/feature/x"> | |||
| 266 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |||
| 267 | + | </li> | |||
| 268 | + | <li> | |||
| 269 | + | <a href="/demo/tree/main" aria-current="true"> | |||
| 270 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 271 | + | </li> | |||
| 272 | + | </ul> | |||
| 273 | + | </section> | |||
| 274 | + | <section aria-label="Tags"> | |||
| 275 | + | <h2>tags <span class="mute">2</span> | |||
| 276 | + | </h2> | |||
| 277 | + | <ul> | |||
| 278 | + | <li> | |||
| 279 | + | <a href="/demo/tree/v0.1.0"> | |||
| 280 | + | <span class="mark" aria-hidden="true"> </span>v0.1.0</a> | |||
| 281 | + | </li> | |||
| 282 | + | <li> | |||
| 283 | + | <a href="/demo/tree/v0.2.0"> | |||
| 284 | + | <span class="mark" aria-hidden="true"> </span>v0.2.0</a> | |||
| 285 | + | </li> | |||
| 286 | + | </ul> | |||
| 287 | + | </section> | |||
| 288 | + | </div> | |||
| 289 | + | </dialog> | |||
| 290 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 291 | + | <div class="panel-head"> | |||
| 292 | + | <label for="cmd-q" class="hi">:</label> | |||
| 293 | + | <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(); | |||
| 294 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 295 | + | 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(); | |||
| 296 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 297 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 298 | + | 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' })) } | |||
| 299 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 300 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 301 | + | </div> | |||
| 302 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 303 | + | <li data-k="go home" data-cursor> | |||
| 304 | + | <a href="/" tabindex="-1"> | |||
| 305 | + | <span class="k">go</span>home</a> | |||
| 306 | + | </li> | |||
| 307 | + | <li data-k="section summary"> | |||
| 308 | + | <a href="/demo" tabindex="-1"> | |||
| 309 | + | <span class="k">section</span>summary</a> | |||
| 310 | + | </li> | |||
| 311 | + | <li data-k="section tree"> | |||
| 312 | + | <a href="/demo/tree/main" tabindex="-1"> | |||
| 313 | + | <span class="k">section</span>tree</a> | |||
| 314 | + | </li> | |||
| 315 | + | <li data-k="section commits"> | |||
| 316 | + | <a href="/demo/commits/main" tabindex="-1"> | |||
| 317 | + | <span class="k">section</span>commits</a> | |||
| 318 | + | </li> | |||
| 319 | + | <li data-k="section refs"> | |||
| 320 | + | <a href="/demo/refs" tabindex="-1"> | |||
| 321 | + | <span class="k">section</span>refs</a> | |||
| 322 | + | </li> | |||
| 323 | + | <li data-k="branch feature/x"> | |||
| 324 | + | <a href="/demo/tree/feature/x" tabindex="-1"> | |||
| 325 | + | <span class="k">branch</span>feature/x</a> | |||
| 326 | + | </li> | |||
| 327 | + | <li data-k="branch main"> | |||
| 328 | + | <a href="/demo/tree/main" tabindex="-1"> | |||
| 329 | + | <span class="k">branch</span>main</a> | |||
| 330 | + | </li> | |||
| 331 | + | <li data-k="tag v0.1.0"> | |||
| 332 | + | <a href="/demo/tree/v0.1.0" tabindex="-1"> | |||
| 333 | + | <span class="k">tag</span>v0.1.0</a> | |||
| 334 | + | </li> | |||
| 335 | + | <li data-k="tag v0.2.0"> | |||
| 336 | + | <a href="/demo/tree/v0.2.0" tabindex="-1"> | |||
| 337 | + | <span class="k">tag</span>v0.2.0</a> | |||
| 338 | + | </li> | |||
| 339 | + | <li data-k="repo quiet"> | |||
| 340 | + | <a href="/quiet" tabindex="-1"> | |||
| 341 | + | <span class="k">repo</span>quiet</a> | |||
| 342 | + | </li> | |||
| 343 | + | </ul> | |||
| 344 | + | </dialog> | |||
| 345 | + | <footer class="bar status" aria-label="Status"> | |||
| 346 | + | <span class="mode">refs</span> | |||
| 347 | + | <span class="st-ctx">2 branches · 2 tags</span> | |||
| 348 | + | <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'))"> | |||
| 349 | + | <span class="hi">:</span> go to…</button> | |||
| 350 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 351 | + | </span> | |||
| 352 | + | <span class="st-hints">j/k move · enter open · / filter · ? help</span> | |||
| 353 | + | <span class="st-src"> | |||
| 354 | + | <span class="st-ver">ermite devel · </span> | |||
| 355 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 356 | + | </span> | |||
| 357 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 358 | + | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/4</span> | |||
| 359 | + | </footer> | |||
| 360 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 361 | + | if (q('dialog[open]').count) return; | |||
| 362 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 363 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 364 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 365 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 366 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 367 | + | })(1); | |||
| 368 | + | k-k from:body -> ((d) => { | |||
| 369 | + | if (q('dialog[open]').count) return; | |||
| 370 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 371 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 372 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 373 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 374 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 375 | + | })(-1); | |||
| 376 | + | k-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 377 | + | 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)); | |||
| 378 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 379 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 380 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 381 | + | 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()); | |||
| 382 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 383 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 384 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 385 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 386 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 387 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 388 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 389 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 390 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 391 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 392 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 393 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 394 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 395 | + | k-permalink from:body -> { | |||
| 396 | + | let b = document.body.dataset; | |||
| 397 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 398 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 399 | + | navigator.clipboard?.writeText(location.href); | |||
| 400 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 401 | + | }"> | |||
| 402 | + | </div> | |||
| 403 | + | </body> | |||
| 404 | + | </html> | |||
A cmd/ermite/testdata/web/summary.html +398 -0
| @@ -0,0 +1,398 @@ | |||
| 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>demo · example.test</title> | |
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |
| 10 | + | </script> | |
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |
| 12 | + | </script> | |
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |
| 14 | + | </script> | |
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |
| 16 | + | </script> | |
| 17 | + | </head> | |
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |
| 19 | + | <a class="skip" href="#main">skip to content</a> | |
| 20 | + | <header class="bar top"> | |
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |
| 22 | + | <span class="fade" aria-hidden="true"> | |
| 23 | + | </span> | |
| 24 | + | <nav class="crumbs" aria-label="Path"> | |
| 25 | + | <ol> | |
| 26 | + | <li> | |
| 27 | + | <a href="/demo" aria-current="page">demo</a> | |
| 28 | + | </li> | |
| 29 | + | </ol> | |
| 30 | + | </nav> | |
| 31 | + | <nav class="secs" aria-label="Sections"> | |
| 32 | + | <ul> | |
| 33 | + | <li> | |
| 34 | + | <a href="/demo" data-key="s" aria-keyshortcuts="g s" aria-current="page">summary</a> | |
| 35 | + | </li> | |
| 36 | + | <li> | |
| 37 | + | <a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |
| 38 | + | </li> | |
| 39 | + | <li> | |
| 40 | + | <a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |
| 41 | + | </li> | |
| 42 | + | <li> | |
| 43 | + | <a href="/demo/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |
| 44 | + | </li> | |
| 45 | + | </ul> | |
| 46 | + | </nav> | |
| 47 | + | <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> | |
| 48 | + | </button> | |
| 49 | + | </header> | |
| 50 | + | <div class="work has-rail read"> | |
| 51 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |
| 52 | + | <p class="lede">A demo repository</p> | |
| 53 | + | <h2 class="rule-h"> | |
| 54 | + | <span>README.md</span> | |
| 55 | + | </h2> | |
| 56 | + | <article class="prose" aria-label="README.md"> | |
| 57 | + | <h1 id="md-demo">Demo</h1> | |
| 58 | + | <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p> | |
| 59 | + | <h2 id="md-install">Install</h2> | |
| 60 | + | <pre> | |
| 61 | + | <code class="language-sh">go install example.org/demo@latest | |
| 62 | + | </code> | |
| 63 | + | </pre> | |
| 64 | + | <h2 id="md-usage">Usage</h2> | |
| 65 | + | <table> | |
| 66 | + | <thead> | |
| 67 | + | <tr> | |
| 68 | + | <th>a</th> | |
| 69 | + | <th align="right">b</th> | |
| 70 | + | </tr> | |
| 71 | + | </thead> | |
| 72 | + | <tbody> | |
| 73 | + | <tr> | |
| 74 | + | <td>1</td> | |
| 75 | + | <td align="right">2</td> | |
| 76 | + | </tr> | |
| 77 | + | </tbody> | |
| 78 | + | </table> | |
| 79 | + | </article> | |
| 80 | + | </main> | |
| 81 | + | <aside class="rail" aria-label="Context"> | |
| 82 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |
| 83 | + | <summary>clone · facts · recent commits · outline</summary> | |
| 84 | + | <section aria-labelledby="r-clone"> | |
| 85 | + | <h2 id="r-clone">clone</h2> | |
| 86 | + | <div class="clone" id="clone-demo"> | |
| 87 | + | <div class="clone-line"> | |
| 88 | + | <span class="mute" aria-hidden="true">$ </span> | |
| 89 | + | <code>git clone http://example.test/demo.git</code> | |
| 90 | + | <button type="button" class="copy" data-label="[copy]" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button> | |
| 91 | + | </div> | |
| 92 | + | </div> | |
| 93 | + | </section> | |
| 94 | + | <section aria-labelledby="r-facts"> | |
| 95 | + | <h2 id="r-facts">facts</h2> | |
| 96 | + | <dl class="kv"> | |
| 97 | + | <dt>last push</dt> | |
| 98 | + | <dd id="last-push-demo"> | |
| 99 | + | <time datetime="STAMP">AGO · STAMP</time> | |
| 100 | + | </dd> | |
| 101 | + | <dt>branch</dt> | |
| 102 | + | <dd>main · 2 branches</dd> | |
| 103 | + | <dt>tag</dt> | |
| 104 | + | <dd>v0.2.0 · AGO · 2 tags</dd> | |
| 105 | + | <dt>created</dt> | |
| 106 | + | <dd> | |
| 107 | + | <time datetime="STAMP">STAMP</time> | |
| 108 | + | </dd> | |
| 109 | + | </dl> | |
| 110 | + | </section> | |
| 111 | + | <section aria-labelledby="r-recent"> | |
| 112 | + | <h2 id="r-recent">recent commits <a href="/demo/commits/main">all</a> | |
| 113 | + | </h2> | |
| 114 | + | <table class="list commits" aria-label="Commits"> | |
| 115 | + | <tbody data-rows> | |
| 116 | + | <tr data-cursor> | |
| 117 | + | <td class="cur" aria-hidden="true"> | |
| 118 | + | </td> | |
| 119 | + | <td class="c-sha mute">SHA7</td> | |
| 120 | + | <td class="c-subject"> | |
| 121 | + | <a href="/demo/commit/SHA40">add main.go</a> | |
| 122 | + | <span class="tagged">[main]</span> | |
| 123 | + | <span class="tagged">[tag: v0.2.0]</span> | |
| 124 | + | </td> | |
| 125 | + | <td class="c-who dim">T</td> | |
| 126 | + | <td class="c-when nowrap dim"> | |
| 127 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 128 | + | </td> | |
| 129 | + | </tr> | |
| 130 | + | <tr> | |
| 131 | + | <td class="cur" aria-hidden="true"> | |
| 132 | + | </td> | |
| 133 | + | <td class="c-sha mute">SHA7</td> | |
| 134 | + | <td class="c-subject"> | |
| 135 | + | <a href="/demo/commit/SHA40">first commit</a> | |
| 136 | + | <span class="tagged">[tag: v0.1.0]</span> | |
| 137 | + | </td> | |
| 138 | + | <td class="c-who dim">T</td> | |
| 139 | + | <td class="c-when nowrap dim"> | |
| 140 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |
| 141 | + | </td> | |
| 142 | + | </tr> | |
| 143 | + | </tbody> | |
| 144 | + | </table> | |
| 145 | + | </section> | |
| 146 | + | <section aria-labelledby="r-outline"> | |
| 147 | + | <h2 id="r-outline">README.md</h2> | |
| 148 | + | <nav class="outline" aria-label="Outline"> | |
| 149 | + | <a class="l1" href="#md-demo">Demo</a> | |
| 150 | + | <a class="l2" href="#md-install">Install</a> | |
| 151 | + | <a class="l2" href="#md-usage">Usage</a> | |
| 152 | + | </nav> | |
| 153 | + | </section> | |
| 154 | + | </details> | |
| 155 | + | </aside> | |
| 156 | + | </div> | |
| 157 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 158 | + | <div class="panel-head"> | |
| 159 | + | <span>keys</span> | |
| 160 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 161 | + | </div> | |
| 162 | + | <dl class="keys"> | |
| 163 | + | <div> | |
| 164 | + | <dt> | |
| 165 | + | <kbd>j</kbd> | |
| 166 | + | <kbd>k</kbd> | |
| 167 | + | </dt> | |
| 168 | + | <dd>move cursor down / up</dd> | |
| 169 | + | </div> | |
| 170 | + | <div> | |
| 171 | + | <dt> | |
| 172 | + | <kbd>enter</kbd> | |
| 173 | + | </dt> | |
| 174 | + | <dd>open row under cursor</dd> | |
| 175 | + | </div> | |
| 176 | + | <div> | |
| 177 | + | <dt> | |
| 178 | + | <kbd>g</kbd> | |
| 179 | + | <kbd>g</kbd> / <kbd>G</kbd> | |
| 180 | + | </dt> | |
| 181 | + | <dd>first / last row</dd> | |
| 182 | + | </div> | |
| 183 | + | <div> | |
| 184 | + | <dt> | |
| 185 | + | <kbd>/</kbd> | |
| 186 | + | </dt> | |
| 187 | + | <dd>focus the filter</dd> | |
| 188 | + | </div> | |
| 189 | + | <div> | |
| 190 | + | <dt> | |
| 191 | + | <kbd>g h</kbd> | |
| 192 | + | <kbd>g t</kbd> | |
| 193 | + | </dt> | |
| 194 | + | <dd>go home / tree</dd> | |
| 195 | + | </div> | |
| 196 | + | <div> | |
| 197 | + | <dt> | |
| 198 | + | <kbd>g c</kbd> | |
| 199 | + | <kbd>g r</kbd> | |
| 200 | + | </dt> | |
| 201 | + | <dd>go commits / refs</dd> | |
| 202 | + | </div> | |
| 203 | + | <div> | |
| 204 | + | <dt> | |
| 205 | + | <kbd>b</kbd> | |
| 206 | + | </dt> | |
| 207 | + | <dd>switch ref (branches, tags)</dd> | |
| 208 | + | </div> | |
| 209 | + | <div> | |
| 210 | + | <dt> | |
| 211 | + | <kbd>:</kbd> | |
| 212 | + | <kbd>f</kbd> | |
| 213 | + | </dt> | |
| 214 | + | <dd>go to: section, ref, file, repo</dd> | |
| 215 | + | </div> | |
| 216 | + | <div> | |
| 217 | + | <dt> | |
| 218 | + | <kbd>y</kbd> | |
| 219 | + | </dt> | |
| 220 | + | <dd>permalink: ref becomes the full sha</dd> | |
| 221 | + | </div> | |
| 222 | + | <div> | |
| 223 | + | <dt> | |
| 224 | + | <kbd>s</kbd> | |
| 225 | + | <kbd>t</kbd> | |
| 226 | + | <kbd>c</kbd> | |
| 227 | + | <kbd>r</kbd> | |
| 228 | + | </dt> | |
| 229 | + | <dd>repo sections (when shown)</dd> | |
| 230 | + | </div> | |
| 231 | + | <div> | |
| 232 | + | <dt> | |
| 233 | + | <kbd>d</kbd> | |
| 234 | + | <kbd>u</kbd> | |
| 235 | + | <kbd>x</kbd> | |
| 236 | + | </dt> | |
| 237 | + | <dd>commit: split / unified / collapse all</dd> | |
| 238 | + | </div> | |
| 239 | + | <div> | |
| 240 | + | <dt> | |
| 241 | + | <kbd>esc</kbd> | |
| 242 | + | </dt> | |
| 243 | + | <dd>close panel</dd> | |
| 244 | + | </div> | |
| 245 | + | </dl> | |
| 246 | + | </dialog> | |
| 247 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 248 | + | <div class="panel-head"> | |
| 249 | + | <label for="refs-filter">ref></label> | |
| 250 | + | <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))"> | |
| 251 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 252 | + | </div> | |
| 253 | + | <div class="panel-cols" data-picklist> | |
| 254 | + | <section aria-label="Branches"> | |
| 255 | + | <h2>branches <span class="mute">2</span> | |
| 256 | + | </h2> | |
| 257 | + | <ul> | |
| 258 | + | <li> | |
| 259 | + | <a href="/demo/tree/feature/x"> | |
| 260 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |
| 261 | + | </li> | |
| 262 | + | <li> | |
| 263 | + | <a href="/demo/tree/main" aria-current="true"> | |
| 264 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |
| 265 | + | </li> | |
| 266 | + | </ul> | |
| 267 | + | </section> | |
| 268 | + | <section aria-label="Tags"> | |
| 269 | + | <h2>tags <span class="mute">2</span> | |
| 270 | + | </h2> | |
| 271 | + | <ul> | |
| 272 | + | <li> | |
| 273 | + | <a href="/demo/tree/v0.1.0"> | |
| 274 | + | <span class="mark" aria-hidden="true"> </span>v0.1.0</a> | |
| 275 | + | </li> | |
| 276 | + | <li> | |
| 277 | + | <a href="/demo/tree/v0.2.0"> | |
| 278 | + | <span class="mark" aria-hidden="true"> </span>v0.2.0</a> | |
| 279 | + | </li> | |
| 280 | + | </ul> | |
| 281 | + | </section> | |
| 282 | + | </div> | |
| 283 | + | </dialog> | |
| 284 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |
| 285 | + | <div class="panel-head"> | |
| 286 | + | <label for="cmd-q" class="hi">:</label> | |
| 287 | + | <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(); | |
| 288 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |
| 289 | + | 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(); | |
| 290 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |
| 291 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |
| 292 | + | 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' })) } | |
| 293 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |
| 294 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |
| 295 | + | </div> | |
| 296 | + | <ul id="cmd-list" class="cmd-list"> | |
| 297 | + | <li data-k="go home" data-cursor> | |
| 298 | + | <a href="/" tabindex="-1"> | |
| 299 | + | <span class="k">go</span>home</a> | |
| 300 | + | </li> | |
| 301 | + | <li data-k="section summary"> | |
| 302 | + | <a href="/demo" tabindex="-1"> | |
| 303 | + | <span class="k">section</span>summary</a> | |
| 304 | + | </li> | |
| 305 | + | <li data-k="section tree"> | |
| 306 | + | <a href="/demo/tree/main" tabindex="-1"> | |
| 307 | + | <span class="k">section</span>tree</a> | |
| 308 | + | </li> | |
| 309 | + | <li data-k="section commits"> | |
| 310 | + | <a href="/demo/commits/main" tabindex="-1"> | |
| 311 | + | <span class="k">section</span>commits</a> | |
| 312 | + | </li> | |
| 313 | + | <li data-k="section refs"> | |
| 314 | + | <a href="/demo/refs" tabindex="-1"> | |
| 315 | + | <span class="k">section</span>refs</a> | |
| 316 | + | </li> | |
| 317 | + | <li data-k="branch feature/x"> | |
| 318 | + | <a href="/demo/tree/feature/x" tabindex="-1"> | |
| 319 | + | <span class="k">branch</span>feature/x</a> | |
| 320 | + | </li> | |
| 321 | + | <li data-k="branch main"> | |
| 322 | + | <a href="/demo/tree/main" tabindex="-1"> | |
| 323 | + | <span class="k">branch</span>main</a> | |
| 324 | + | </li> | |
| 325 | + | <li data-k="tag v0.1.0"> | |
| 326 | + | <a href="/demo/tree/v0.1.0" tabindex="-1"> | |
| 327 | + | <span class="k">tag</span>v0.1.0</a> | |
| 328 | + | </li> | |
| 329 | + | <li data-k="tag v0.2.0"> | |
| 330 | + | <a href="/demo/tree/v0.2.0" tabindex="-1"> | |
| 331 | + | <span class="k">tag</span>v0.2.0</a> | |
| 332 | + | </li> | |
| 333 | + | <li data-k="repo quiet"> | |
| 334 | + | <a href="/quiet" tabindex="-1"> | |
| 335 | + | <span class="k">repo</span>quiet</a> | |
| 336 | + | </li> | |
| 337 | + | </ul> | |
| 338 | + | </dialog> | |
| 339 | + | <footer class="bar status" aria-label="Status"> | |
| 340 | + | <span class="mode">summary</span> | |
| 341 | + | <span class="st-ctx">2 recent commits</span> | |
| 342 | + | <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'))"> | |
| 343 | + | <span class="hi">:</span> go to…</button> | |
| 344 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |
| 345 | + | </span> | |
| 346 | + | <span class="st-hints">j/k move · enter open · b ref · y permalink · ? help</span> | |
| 347 | + | <span class="st-src"> | |
| 348 | + | <span class="st-ver">ermite devel · </span> | |
| 349 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |
| 350 | + | </span> | |
| 351 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |
| 352 | + | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | |
| 353 | + | </footer> | |
| 354 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |
| 355 | + | if (q('dialog[open]').count) return; | |
| 356 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |
| 357 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |
| 358 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |
| 359 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |
| 360 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |
| 361 | + | })(1); | |
| 362 | + | k-k 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-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |
| 371 | + | 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)); | |
| 372 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |
| 373 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |
| 374 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |
| 375 | + | 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()); | |
| 376 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |
| 377 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |
| 378 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |
| 379 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 380 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 381 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 382 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |
| 383 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |
| 384 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |
| 385 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |
| 386 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |
| 387 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |
| 388 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |
| 389 | + | k-permalink from:body -> { | |
| 390 | + | let b = document.body.dataset; | |
| 391 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |
| 392 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |
| 393 | + | navigator.clipboard?.writeText(location.href); | |
| 394 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |
| 395 | + | }"> | |
| 396 | + | </div> | |
| 397 | + | </body> | |
| 398 | + | </html> | |
| @@ -0,0 +1,398 @@ | |||||
| 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>demo · example.test</title> | |||
| 8 | + | <link rel="stylesheet" href="/-/static/css/ermite.HASH.css"> | |||
| 9 | + | <script src="/-/static/js/htmx.min.HASH.js" defer> | |||
| 10 | + | </script> | |||
| 11 | + | <script src="/-/static/js/hx-live.min.HASH.js" defer> | |||
| 12 | + | </script> | |||
| 13 | + | <script src="/-/static/js/hx-sse.min.HASH.js" defer> | |||
| 14 | + | </script> | |||
| 15 | + | <script src="/-/static/js/ermite.HASH.js" defer> | |||
| 16 | + | </script> | |||
| 17 | + | </head> | |||
| 18 | + | <body data-ref="main" data-sha="SHA40" data-sel=""> | |||
| 19 | + | <a class="skip" href="#main">skip to content</a> | |||
| 20 | + | <header class="bar top"> | |||
| 21 | + | <a class="blk" href="/" data-g="h" title="home (g h)">example.test</a> | |||
| 22 | + | <span class="fade" aria-hidden="true"> | |||
| 23 | + | </span> | |||
| 24 | + | <nav class="crumbs" aria-label="Path"> | |||
| 25 | + | <ol> | |||
| 26 | + | <li> | |||
| 27 | + | <a href="/demo" aria-current="page">demo</a> | |||
| 28 | + | </li> | |||
| 29 | + | </ol> | |||
| 30 | + | </nav> | |||
| 31 | + | <nav class="secs" aria-label="Sections"> | |||
| 32 | + | <ul> | |||
| 33 | + | <li> | |||
| 34 | + | <a href="/demo" data-key="s" aria-keyshortcuts="g s" aria-current="page">summary</a> | |||
| 35 | + | </li> | |||
| 36 | + | <li> | |||
| 37 | + | <a href="/demo/tree/main" data-key="t" aria-keyshortcuts="g t">tree</a> | |||
| 38 | + | </li> | |||
| 39 | + | <li> | |||
| 40 | + | <a href="/demo/commits/main" data-key="c" aria-keyshortcuts="g c">commits</a> | |||
| 41 | + | </li> | |||
| 42 | + | <li> | |||
| 43 | + | <a href="/demo/refs" data-key="r" aria-keyshortcuts="g r">refs</a> | |||
| 44 | + | </li> | |||
| 45 | + | </ul> | |||
| 46 | + | </nav> | |||
| 47 | + | <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> | |||
| 48 | + | </button> | |||
| 49 | + | </header> | |||
| 50 | + | <div class="work has-rail read"> | |||
| 51 | + | <main id="main" tabindex="-1" data-y="0" hx-on:scroll="this.dataset.y = Math.round(this.scrollTop / 60)"> | |||
| 52 | + | <p class="lede">A demo repository</p> | |||
| 53 | + | <h2 class="rule-h"> | |||
| 54 | + | <span>README.md</span> | |||
| 55 | + | </h2> | |||
| 56 | + | <article class="prose" aria-label="README.md"> | |||
| 57 | + | <h1 id="md-demo">Demo</h1> | |||
| 58 | + | <p>A small <em>demo</em> with a <a href="/demo/blob/main/docs/guide.md">guide</a> and <a href="https://example.org/x" rel="nofollow noopener noreferrer">the site</a>.</p> | |||
| 59 | + | <h2 id="md-install">Install</h2> | |||
| 60 | + | <pre> | |||
| 61 | + | <code class="language-sh">go install example.org/demo@latest | |||
| 62 | + | </code> | |||
| 63 | + | </pre> | |||
| 64 | + | <h2 id="md-usage">Usage</h2> | |||
| 65 | + | <table> | |||
| 66 | + | <thead> | |||
| 67 | + | <tr> | |||
| 68 | + | <th>a</th> | |||
| 69 | + | <th align="right">b</th> | |||
| 70 | + | </tr> | |||
| 71 | + | </thead> | |||
| 72 | + | <tbody> | |||
| 73 | + | <tr> | |||
| 74 | + | <td>1</td> | |||
| 75 | + | <td align="right">2</td> | |||
| 76 | + | </tr> | |||
| 77 | + | </tbody> | |||
| 78 | + | </table> | |||
| 79 | + | </article> | |||
| 80 | + | </main> | |||
| 81 | + | <aside class="rail" aria-label="Context"> | |||
| 82 | + | <details class="rail-d" open data-init="" hx-live="if (!data.init) { data.init = '1'; this.open = matchMedia('(min-width: 1100px)').matches }"> | |||
| 83 | + | <summary>clone · facts · recent commits · outline</summary> | |||
| 84 | + | <section aria-labelledby="r-clone"> | |||
| 85 | + | <h2 id="r-clone">clone</h2> | |||
| 86 | + | <div class="clone" id="clone-demo"> | |||
| 87 | + | <div class="clone-line"> | |||
| 88 | + | <span class="mute" aria-hidden="true">$ </span> | |||
| 89 | + | <code>git clone http://example.test/demo.git</code> | |||
| 90 | + | <button type="button" class="copy" data-label="[copy]" aria-label="copy: git clone http://example.test/demo.git" hx-on:click="navigator.clipboard?.writeText(closest('.clone-line').querySelector('code').textContent); this.textContent = '[copied]'; await timeout('1500ms'); this.textContent = this.dataset.label">[copy]</button> | |||
| 91 | + | </div> | |||
| 92 | + | </div> | |||
| 93 | + | </section> | |||
| 94 | + | <section aria-labelledby="r-facts"> | |||
| 95 | + | <h2 id="r-facts">facts</h2> | |||
| 96 | + | <dl class="kv"> | |||
| 97 | + | <dt>last push</dt> | |||
| 98 | + | <dd id="last-push-demo"> | |||
| 99 | + | <time datetime="STAMP">AGO · STAMP</time> | |||
| 100 | + | </dd> | |||
| 101 | + | <dt>branch</dt> | |||
| 102 | + | <dd>main · 2 branches</dd> | |||
| 103 | + | <dt>tag</dt> | |||
| 104 | + | <dd>v0.2.0 · AGO · 2 tags</dd> | |||
| 105 | + | <dt>created</dt> | |||
| 106 | + | <dd> | |||
| 107 | + | <time datetime="STAMP">STAMP</time> | |||
| 108 | + | </dd> | |||
| 109 | + | </dl> | |||
| 110 | + | </section> | |||
| 111 | + | <section aria-labelledby="r-recent"> | |||
| 112 | + | <h2 id="r-recent">recent commits <a href="/demo/commits/main">all</a> | |||
| 113 | + | </h2> | |||
| 114 | + | <table class="list commits" aria-label="Commits"> | |||
| 115 | + | <tbody data-rows> | |||
| 116 | + | <tr data-cursor> | |||
| 117 | + | <td class="cur" aria-hidden="true"> | |||
| 118 | + | </td> | |||
| 119 | + | <td class="c-sha mute">SHA7</td> | |||
| 120 | + | <td class="c-subject"> | |||
| 121 | + | <a href="/demo/commit/SHA40">add main.go</a> | |||
| 122 | + | <span class="tagged">[main]</span> | |||
| 123 | + | <span class="tagged">[tag: v0.2.0]</span> | |||
| 124 | + | </td> | |||
| 125 | + | <td class="c-who dim">T</td> | |||
| 126 | + | <td class="c-when nowrap dim"> | |||
| 127 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 128 | + | </td> | |||
| 129 | + | </tr> | |||
| 130 | + | <tr> | |||
| 131 | + | <td class="cur" aria-hidden="true"> | |||
| 132 | + | </td> | |||
| 133 | + | <td class="c-sha mute">SHA7</td> | |||
| 134 | + | <td class="c-subject"> | |||
| 135 | + | <a href="/demo/commit/SHA40">first commit</a> | |||
| 136 | + | <span class="tagged">[tag: v0.1.0]</span> | |||
| 137 | + | </td> | |||
| 138 | + | <td class="c-who dim">T</td> | |||
| 139 | + | <td class="c-when nowrap dim"> | |||
| 140 | + | <time datetime="STAMP" title="STAMP">AGO</time> | |||
| 141 | + | </td> | |||
| 142 | + | </tr> | |||
| 143 | + | </tbody> | |||
| 144 | + | </table> | |||
| 145 | + | </section> | |||
| 146 | + | <section aria-labelledby="r-outline"> | |||
| 147 | + | <h2 id="r-outline">README.md</h2> | |||
| 148 | + | <nav class="outline" aria-label="Outline"> | |||
| 149 | + | <a class="l1" href="#md-demo">Demo</a> | |||
| 150 | + | <a class="l2" href="#md-install">Install</a> | |||
| 151 | + | <a class="l2" href="#md-usage">Usage</a> | |||
| 152 | + | </nav> | |||
| 153 | + | </section> | |||
| 154 | + | </details> | |||
| 155 | + | </aside> | |||
| 156 | + | </div> | |||
| 157 | + | <dialog id="panel-help" class="panel" aria-label="Keyboard shortcuts" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 158 | + | <div class="panel-head"> | |||
| 159 | + | <span>keys</span> | |||
| 160 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 161 | + | </div> | |||
| 162 | + | <dl class="keys"> | |||
| 163 | + | <div> | |||
| 164 | + | <dt> | |||
| 165 | + | <kbd>j</kbd> | |||
| 166 | + | <kbd>k</kbd> | |||
| 167 | + | </dt> | |||
| 168 | + | <dd>move cursor down / up</dd> | |||
| 169 | + | </div> | |||
| 170 | + | <div> | |||
| 171 | + | <dt> | |||
| 172 | + | <kbd>enter</kbd> | |||
| 173 | + | </dt> | |||
| 174 | + | <dd>open row under cursor</dd> | |||
| 175 | + | </div> | |||
| 176 | + | <div> | |||
| 177 | + | <dt> | |||
| 178 | + | <kbd>g</kbd> | |||
| 179 | + | <kbd>g</kbd> / <kbd>G</kbd> | |||
| 180 | + | </dt> | |||
| 181 | + | <dd>first / last row</dd> | |||
| 182 | + | </div> | |||
| 183 | + | <div> | |||
| 184 | + | <dt> | |||
| 185 | + | <kbd>/</kbd> | |||
| 186 | + | </dt> | |||
| 187 | + | <dd>focus the filter</dd> | |||
| 188 | + | </div> | |||
| 189 | + | <div> | |||
| 190 | + | <dt> | |||
| 191 | + | <kbd>g h</kbd> | |||
| 192 | + | <kbd>g t</kbd> | |||
| 193 | + | </dt> | |||
| 194 | + | <dd>go home / tree</dd> | |||
| 195 | + | </div> | |||
| 196 | + | <div> | |||
| 197 | + | <dt> | |||
| 198 | + | <kbd>g c</kbd> | |||
| 199 | + | <kbd>g r</kbd> | |||
| 200 | + | </dt> | |||
| 201 | + | <dd>go commits / refs</dd> | |||
| 202 | + | </div> | |||
| 203 | + | <div> | |||
| 204 | + | <dt> | |||
| 205 | + | <kbd>b</kbd> | |||
| 206 | + | </dt> | |||
| 207 | + | <dd>switch ref (branches, tags)</dd> | |||
| 208 | + | </div> | |||
| 209 | + | <div> | |||
| 210 | + | <dt> | |||
| 211 | + | <kbd>:</kbd> | |||
| 212 | + | <kbd>f</kbd> | |||
| 213 | + | </dt> | |||
| 214 | + | <dd>go to: section, ref, file, repo</dd> | |||
| 215 | + | </div> | |||
| 216 | + | <div> | |||
| 217 | + | <dt> | |||
| 218 | + | <kbd>y</kbd> | |||
| 219 | + | </dt> | |||
| 220 | + | <dd>permalink: ref becomes the full sha</dd> | |||
| 221 | + | </div> | |||
| 222 | + | <div> | |||
| 223 | + | <dt> | |||
| 224 | + | <kbd>s</kbd> | |||
| 225 | + | <kbd>t</kbd> | |||
| 226 | + | <kbd>c</kbd> | |||
| 227 | + | <kbd>r</kbd> | |||
| 228 | + | </dt> | |||
| 229 | + | <dd>repo sections (when shown)</dd> | |||
| 230 | + | </div> | |||
| 231 | + | <div> | |||
| 232 | + | <dt> | |||
| 233 | + | <kbd>d</kbd> | |||
| 234 | + | <kbd>u</kbd> | |||
| 235 | + | <kbd>x</kbd> | |||
| 236 | + | </dt> | |||
| 237 | + | <dd>commit: split / unified / collapse all</dd> | |||
| 238 | + | </div> | |||
| 239 | + | <div> | |||
| 240 | + | <dt> | |||
| 241 | + | <kbd>esc</kbd> | |||
| 242 | + | </dt> | |||
| 243 | + | <dd>close panel</dd> | |||
| 244 | + | </div> | |||
| 245 | + | </dl> | |||
| 246 | + | </dialog> | |||
| 247 | + | <dialog id="panel-refs" class="panel" aria-label="Switch ref" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 248 | + | <div class="panel-head"> | |||
| 249 | + | <label for="refs-filter">ref></label> | |||
| 250 | + | <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))"> | |||
| 251 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 252 | + | </div> | |||
| 253 | + | <div class="panel-cols" data-picklist> | |||
| 254 | + | <section aria-label="Branches"> | |||
| 255 | + | <h2>branches <span class="mute">2</span> | |||
| 256 | + | </h2> | |||
| 257 | + | <ul> | |||
| 258 | + | <li> | |||
| 259 | + | <a href="/demo/tree/feature/x"> | |||
| 260 | + | <span class="mark" aria-hidden="true"> </span>feature/x</a> | |||
| 261 | + | </li> | |||
| 262 | + | <li> | |||
| 263 | + | <a href="/demo/tree/main" aria-current="true"> | |||
| 264 | + | <span class="mark" aria-hidden="true">●</span>main</a> | |||
| 265 | + | </li> | |||
| 266 | + | </ul> | |||
| 267 | + | </section> | |||
| 268 | + | <section aria-label="Tags"> | |||
| 269 | + | <h2>tags <span class="mute">2</span> | |||
| 270 | + | </h2> | |||
| 271 | + | <ul> | |||
| 272 | + | <li> | |||
| 273 | + | <a href="/demo/tree/v0.1.0"> | |||
| 274 | + | <span class="mark" aria-hidden="true"> </span>v0.1.0</a> | |||
| 275 | + | </li> | |||
| 276 | + | <li> | |||
| 277 | + | <a href="/demo/tree/v0.2.0"> | |||
| 278 | + | <span class="mark" aria-hidden="true"> </span>v0.2.0</a> | |||
| 279 | + | </li> | |||
| 280 | + | </ul> | |||
| 281 | + | </section> | |||
| 282 | + | </div> | |||
| 283 | + | </dialog> | |||
| 284 | + | <dialog id="panel-cmd" class="panel" aria-label="Go to" hx-on:keydown="event.key === 'Escape' && (event.preventDefault(), this.close())"> | |||
| 285 | + | <div class="panel-head"> | |||
| 286 | + | <label for="cmd-q" class="hi">:</label> | |||
| 287 | + | <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(); | |||
| 288 | + | q('#cmd-list > li').arr().forEach(li => li.hidden = !li.dataset.k.includes(t)); | |||
| 289 | + | 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(); | |||
| 290 | + | let i = vis.findIndex(li => li.hasAttribute('data-cursor')); | |||
| 291 | + | let step = event.key === 'ArrowDown' ? 1 : event.key === 'ArrowUp' ? -1 : 0; | |||
| 292 | + | 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' })) } | |||
| 293 | + | else if (event.key === 'Enter') { event.preventDefault(); vis[Math.max(i, 0)]?.querySelector('a').click() }"> | |||
| 294 | + | <button type="button" hx-on:click="closest('dialog').close()">[esc] close</button> | |||
| 295 | + | </div> | |||
| 296 | + | <ul id="cmd-list" class="cmd-list"> | |||
| 297 | + | <li data-k="go home" data-cursor> | |||
| 298 | + | <a href="/" tabindex="-1"> | |||
| 299 | + | <span class="k">go</span>home</a> | |||
| 300 | + | </li> | |||
| 301 | + | <li data-k="section summary"> | |||
| 302 | + | <a href="/demo" tabindex="-1"> | |||
| 303 | + | <span class="k">section</span>summary</a> | |||
| 304 | + | </li> | |||
| 305 | + | <li data-k="section tree"> | |||
| 306 | + | <a href="/demo/tree/main" tabindex="-1"> | |||
| 307 | + | <span class="k">section</span>tree</a> | |||
| 308 | + | </li> | |||
| 309 | + | <li data-k="section commits"> | |||
| 310 | + | <a href="/demo/commits/main" tabindex="-1"> | |||
| 311 | + | <span class="k">section</span>commits</a> | |||
| 312 | + | </li> | |||
| 313 | + | <li data-k="section refs"> | |||
| 314 | + | <a href="/demo/refs" tabindex="-1"> | |||
| 315 | + | <span class="k">section</span>refs</a> | |||
| 316 | + | </li> | |||
| 317 | + | <li data-k="branch feature/x"> | |||
| 318 | + | <a href="/demo/tree/feature/x" tabindex="-1"> | |||
| 319 | + | <span class="k">branch</span>feature/x</a> | |||
| 320 | + | </li> | |||
| 321 | + | <li data-k="branch main"> | |||
| 322 | + | <a href="/demo/tree/main" tabindex="-1"> | |||
| 323 | + | <span class="k">branch</span>main</a> | |||
| 324 | + | </li> | |||
| 325 | + | <li data-k="tag v0.1.0"> | |||
| 326 | + | <a href="/demo/tree/v0.1.0" tabindex="-1"> | |||
| 327 | + | <span class="k">tag</span>v0.1.0</a> | |||
| 328 | + | </li> | |||
| 329 | + | <li data-k="tag v0.2.0"> | |||
| 330 | + | <a href="/demo/tree/v0.2.0" tabindex="-1"> | |||
| 331 | + | <span class="k">tag</span>v0.2.0</a> | |||
| 332 | + | </li> | |||
| 333 | + | <li data-k="repo quiet"> | |||
| 334 | + | <a href="/quiet" tabindex="-1"> | |||
| 335 | + | <span class="k">repo</span>quiet</a> | |||
| 336 | + | </li> | |||
| 337 | + | </ul> | |||
| 338 | + | </dialog> | |||
| 339 | + | <footer class="bar status" aria-label="Status"> | |||
| 340 | + | <span class="mode">summary</span> | |||
| 341 | + | <span class="st-ctx">2 recent commits</span> | |||
| 342 | + | <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'))"> | |||
| 343 | + | <span class="hi">:</span> go to…</button> | |||
| 344 | + | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"> | |||
| 345 | + | </span> | |||
| 346 | + | <span class="st-hints">j/k move · enter open · b ref · y permalink · ? help</span> | |||
| 347 | + | <span class="st-src"> | |||
| 348 | + | <span class="st-ver">ermite devel · </span> | |||
| 349 | + | <a href="http://example.test/ermite" rel="noopener" title="source of this version (AGPL-3.0-or-later)">source</a> | |||
| 350 | + | </span> | |||
| 351 | + | <span class="st-live" data-state="offline" role="status" title="not connected to the event stream">offline</span> | |||
| 352 | + | <span class="st-pos num" title="cursor position" :text="(() => { let all = q('[data-rows] > :not([hidden])').arr(); let i = all.findIndex(r => r.hasAttribute('data-cursor')); return (i + 1) + '/' + all.length })()">1/2</span> | |||
| 353 | + | </footer> | |||
| 354 | + | <div id="keys" hidden hx-on="k-j from:body -> ((d) => { | |||
| 355 | + | if (q('dialog[open]').count) return; | |||
| 356 | + | let rows = q('[data-rows] > :not([hidden])').arr(); | |||
| 357 | + | if (!rows.length) return (scrollBy(0, d * 180), q('#main').arr()[0].scrollBy(0, d * 180)); | |||
| 358 | + | let i = rows.findIndex(r => r.hasAttribute('data-cursor')); | |||
| 359 | + | let n = rows[Math.max(0, Math.min(rows.length - 1, i + d))]; | |||
| 360 | + | q(n).take('data-cursor'); n.scrollIntoView({ block: 'nearest' }) | |||
| 361 | + | })(1); | |||
| 362 | + | k-k 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-top from:body -> q('[data-rows] > :not([hidden])').arr()[0]?.scrollIntoView({ block: 'nearest' }) ?? (scrollTo(0, 0), q('#main').arr()[0].scrollTo(0, 0)); | |||
| 371 | + | 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)); | |||
| 372 | + | k-open from:body -> q('dialog[open]').count || document.querySelector('[data-cursor] a')?.click(); | |||
| 373 | + | k-esc from:body -> q('dialog[open]').arr().forEach(d => d.close()); | |||
| 374 | + | k-help from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-help').arr()[0].show(); | |||
| 375 | + | 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()); | |||
| 376 | + | k-cmd from:body -> q('dialog[open]').arr().forEach(d => d.close()); q('#panel-cmd').arr()[0].show(); q('#cmd-q').arr()[0].focus(); | |||
| 377 | + | k-filter from:body -> q('[data-filter]').arr()[0]?.focus(); | |||
| 378 | + | k-gh from:body -> document.querySelector('.top a[data-g=h]')?.click(); | |||
| 379 | + | k-gt from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 380 | + | k-gc from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 381 | + | k-gr from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 382 | + | k-s from:body -> document.querySelector('.secs [data-key=s]')?.click(); | |||
| 383 | + | k-t from:body -> document.querySelector('.secs [data-key=t]')?.click(); | |||
| 384 | + | k-c from:body -> document.querySelector('.secs [data-key=c]')?.click(); | |||
| 385 | + | k-r from:body -> document.querySelector('.secs [data-key=r]')?.click(); | |||
| 386 | + | k-split from:body -> q('[data-diff]').attr['data-diff'] = 'split'; sessionStorage.setItem('diff', 'split'); | |||
| 387 | + | k-unified from:body -> q('[data-diff]').attr['data-diff'] = 'unified'; sessionStorage.setItem('diff', 'unified'); | |||
| 388 | + | k-collapse from:body -> q('[data-file]').arr().forEach(d => d.open = false); | |||
| 389 | + | k-permalink from:body -> { | |||
| 390 | + | let b = document.body.dataset; | |||
| 391 | + | if (!b.sha) { b.flash = 'no ref to pin on this page'; await timeout('2s'); b.flash = ''; return } | |||
| 392 | + | history.replaceState(null, '', location.pathname.replace('/' + b.ref + '/', '/' + b.sha + '/') + location.hash); | |||
| 393 | + | navigator.clipboard?.writeText(location.href); | |||
| 394 | + | b.flash = 'permalink ' + b.sha.slice(0, 7) + ' copied'; await timeout('2s'); b.flash = '' | |||
| 395 | + | }"> | |||
| 396 | + | </div> | |||
| 397 | + | </body> | |||
| 398 | + | </html> | |||
A cmd/ermite/web.go +49 -0
| @@ -0,0 +1,49 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "net/http" | |
| 7 | + | ||
| 8 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 9 | + | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 10 | + | "bnema.dev/ermite/internal/adapters/rpc" | |
| 11 | + | "bnema.dev/ermite/internal/adapters/web" | |
| 12 | + | "bnema.dev/ermite/internal/config" | |
| 13 | + | "bnema.dev/ermite/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // newWeb builds the web pages on the API of this process: the generated | |
| 17 | + | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | |
| 18 | + | func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |
| 19 | + | hc := api.InProcessClient() | |
| 20 | + | srv, err := web.New(web.Config{ | |
| 21 | + | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |
| 22 | + | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | |
| 23 | + | PublicURL: cfg.PublicURL, | |
| 24 | + | SourceURL: cfg.SourceURL, | |
| 25 | + | Version: serverVersion(), | |
| 26 | + | }) | |
| 27 | + | if err != nil { | |
| 28 | + | return nil, err | |
| 29 | + | } | |
| 30 | + | return asPrincipal(domain.Anonymous(), srv), nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // asPrincipal runs next with p as the caller of every request: the API calls | |
| 34 | + | // the web makes carry it (rpc.API.InProcessClient). Browsers are anonymous | |
| 35 | + | // until the web login of P5b brings a session. | |
| 36 | + | func asPrincipal(p domain.Principal, next http.Handler) http.Handler { | |
| 37 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 38 | + | next.ServeHTTP(w, r.WithContext(domain.ContextWithPrincipal(r.Context(), p))) | |
| 39 | + | }) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // webRoutes mounts the web on the patterns it serves. | |
| 43 | + | func webRoutes(h http.Handler) []httpserver.Route { | |
| 44 | + | routes := make([]httpserver.Route, 0, len(web.Patterns)) | |
| 45 | + | for _, p := range web.Patterns { | |
| 46 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: h}) | |
| 47 | + | } | |
| 48 | + | return routes | |
| 49 | + | } | |
| @@ -0,0 +1,49 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "net/http" | |||
| 7 | + | ||||
| 8 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 9 | + | "bnema.dev/ermite/internal/adapters/httpserver" | |||
| 10 | + | "bnema.dev/ermite/internal/adapters/rpc" | |||
| 11 | + | "bnema.dev/ermite/internal/adapters/web" | |||
| 12 | + | "bnema.dev/ermite/internal/config" | |||
| 13 | + | "bnema.dev/ermite/internal/domain" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | // newWeb builds the web pages on the API of this process: the generated | |||
| 17 | + | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | |||
| 18 | + | func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||
| 19 | + | hc := api.InProcessClient() | |||
| 20 | + | srv, err := web.New(web.Config{ | |||
| 21 | + | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |||
| 22 | + | Git: ermiteconnect.NewGitServiceClient(hc, rpc.InProcessURL), | |||
| 23 | + | PublicURL: cfg.PublicURL, | |||
| 24 | + | SourceURL: cfg.SourceURL, | |||
| 25 | + | Version: serverVersion(), | |||
| 26 | + | }) | |||
| 27 | + | if err != nil { | |||
| 28 | + | return nil, err | |||
| 29 | + | } | |||
| 30 | + | return asPrincipal(domain.Anonymous(), srv), nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // asPrincipal runs next with p as the caller of every request: the API calls | |||
| 34 | + | // the web makes carry it (rpc.API.InProcessClient). Browsers are anonymous | |||
| 35 | + | // until the web login of P5b brings a session. | |||
| 36 | + | func asPrincipal(p domain.Principal, next http.Handler) http.Handler { | |||
| 37 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 38 | + | next.ServeHTTP(w, r.WithContext(domain.ContextWithPrincipal(r.Context(), p))) | |||
| 39 | + | }) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // webRoutes mounts the web on the patterns it serves. | |||
| 43 | + | func webRoutes(h http.Handler) []httpserver.Route { | |||
| 44 | + | routes := make([]httpserver.Route, 0, len(web.Patterns)) | |||
| 45 | + | for _, p := range web.Patterns { | |||
| 46 | + | routes = append(routes, httpserver.Route{Pattern: p, Handler: h}) | |||
| 47 | + | } | |||
| 48 | + | return routes | |||
| 49 | + | } | |||
A cmd/ermite/web_test.go +247 -0
| @@ -0,0 +1,247 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "flag" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "regexp" | |
| 12 | + | "strings" | |
| 13 | + | "testing" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite/internal/app" | |
| 19 | + | "bnema.dev/ermite/internal/domain" | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests") | |
| 23 | + | ||
| 24 | + | // webFixtureReadme exercises the Markdown policy and the outline. | |
| 25 | + | const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) and [the site](https://example.org/x).\n\n" + | |
| 26 | + | "## Install\n\n```sh\ngo install example.org/demo@latest\n```\n\n## Usage\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n" | |
| 27 | + | ||
| 28 | + | // webRepo is the repository of the golden pages: public, with a README, two | |
| 29 | + | // branches (one with a slash), two tags and a description. | |
| 30 | + | func (h *harness) webRepo(name string) { | |
| 31 | + | h.t.Helper() | |
| 32 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | |
| 33 | + | Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic, | |
| 34 | + | }) | |
| 35 | + | require.NoError(h.t, err) | |
| 36 | + | wt := h.t.TempDir() | |
| 37 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| 38 | + | commit := func(date, msg string, files map[string]string) { | |
| 39 | + | for p, c := range files { | |
| 40 | + | require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(wt, p)), 0o755)) | |
| 41 | + | require.NoError(h.t, os.WriteFile(filepath.Join(wt, p), []byte(c), 0o644)) | |
| 42 | + | h.mustGit(wt, "add", p) | |
| 43 | + | } | |
| 44 | + | env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date} | |
| 45 | + | out, err := h.gitEnv(env, wt, "commit", "-q", "-m", msg) | |
| 46 | + | require.NoError(h.t, err, out) | |
| 47 | + | } | |
| 48 | + | commit("2026-01-01T10:00:00Z", "first commit", map[string]string{"README.md": webFixtureReadme, "docs/guide.md": "# Guide\n"}) | |
| 49 | + | h.mustGit(wt, "tag", "v0.1.0") | |
| 50 | + | commit("2026-01-02T10:00:00Z", "add main.go", map[string]string{"main.go": "package main\n"}) | |
| 51 | + | h.mustGit(wt, "tag", "-a", "-m", "release", "v0.2.0") | |
| 52 | + | h.mustGit(wt, "checkout", "-q", "-b", "feature/x") | |
| 53 | + | commit("2026-01-03T10:00:00Z", "work on x", map[string]string{"x.txt": "x\n"}) | |
| 54 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | // get does a GET and returns the status, the headers and the body. | |
| 58 | + | func (h *harness) get(path string) (int, http.Header, string) { | |
| 59 | + | h.t.Helper() | |
| 60 | + | resp, err := http.Get(h.url(path)) | |
| 61 | + | require.NoError(h.t, err) | |
| 62 | + | defer func() { _ = resp.Body.Close() }() | |
| 63 | + | b, err := io.ReadAll(resp.Body) | |
| 64 | + | require.NoError(h.t, err) | |
| 65 | + | return resp.StatusCode, resp.Header, string(b) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | var ( | |
| 69 | + | reSHA40 = regexp.MustCompile(`\b[0-9a-f]{40}\b`) | |
| 70 | + | reSHA7 = regexp.MustCompile(`\b[0-9a-f]{7}\b`) | |
| 71 | + | reAsset = regexp.MustCompile(`(/-/static/[a-z]+/[a-z.-]+?)\.[0-9a-f]{12}(\.\w+)`) | |
| 72 | + | reAgo = regexp.MustCompile(`(\bnow\b|\d+(m|h|d|mo|y) ago)`) | |
| 73 | + | reStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}Z| UTC)?`) | |
| 74 | + | reReqID = regexp.MustCompile(`request id: [0-9a-f]{8}`) | |
| 75 | + | reSpaces = regexp.MustCompile(`[ \t]+\n|\n[ \t]*\n+`) | |
| 76 | + | reMarkers = regexp.MustCompile(`>\s*<`) | |
| 77 | + | ) | |
| 78 | + | ||
| 79 | + | // normalise makes a page comparable between runs: it hides what depends on | |
| 80 | + | // the clock and on the commit ids, which hold the commit times. | |
| 81 | + | func normalise(s string) string { | |
| 82 | + | s = reAsset.ReplaceAllString(s, "$1.HASH$2") | |
| 83 | + | s = reSHA40.ReplaceAllString(s, "SHA40") | |
| 84 | + | s = reSHA7.ReplaceAllString(s, "SHA7") | |
| 85 | + | s = reAgo.ReplaceAllString(s, "AGO") | |
| 86 | + | s = reStamp.ReplaceAllString(s, "STAMP") | |
| 87 | + | s = reReqID.ReplaceAllString(s, "request id: ID") | |
| 88 | + | s = reSpaces.ReplaceAllString(s, "\n") | |
| 89 | + | s = reMarkers.ReplaceAllString(s, ">\n<") | |
| 90 | + | return strings.TrimSpace(s) + "\n" | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func golden(t *testing.T, name, got string) { | |
| 94 | + | t.Helper() | |
| 95 | + | path := filepath.Join("testdata", "web", name+".html") | |
| 96 | + | if *updateGolden { | |
| 97 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) | |
| 98 | + | require.NoError(t, os.WriteFile(path, []byte(got), 0o644)) | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | want, err := os.ReadFile(path) | |
| 102 | + | require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update") | |
| 103 | + | assert.Equal(t, string(want), got, name) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // AC1: the pages render for a fixture repository with the structure of the | |
| 107 | + | // mockup; the HTML is compared after normalising times and ids. | |
| 108 | + | func TestWebGolden(t *testing.T) { | |
| 109 | + | h := newHarness(t, harnessOpts{}) | |
| 110 | + | h.webRepo("demo") | |
| 111 | + | h.seed("quiet", domain.VisibilityPublic) // empty repository | |
| 112 | + | ||
| 113 | + | for _, tt := range []struct { | |
| 114 | + | name, path string | |
| 115 | + | status int | |
| 116 | + | }{ | |
| 117 | + | {"home", "/", 200}, | |
| 118 | + | {"summary", "/demo", 200}, | |
| 119 | + | {"refs", "/demo/refs", 200}, | |
| 120 | + | {"empty", "/quiet", 200}, | |
| 121 | + | {"not-found", "/nope", 404}, | |
| 122 | + | {"not-found-page", "/demo/tree/main", 404}, | |
| 123 | + | } { | |
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |
| 125 | + | status, hdr, body := h.get(tt.path) | |
| 126 | + | require.Equal(t, tt.status, status) | |
| 127 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |
| 128 | + | assert.Contains(t, hdr.Get("Content-Type"), "text/html") | |
| 129 | + | golden(t, tt.name, normalise(body)) | |
| 130 | + | }) | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // AC2: a private repository is invisible on the web: not in the home list, | |
| 135 | + | // not in the ":" targets, and its URLs give the same 404 page as a missing | |
| 136 | + | // repository. | |
| 137 | + | func TestWebPrivateRepoIsInvisible(t *testing.T) { | |
| 138 | + | h := newHarness(t, harnessOpts{}) | |
| 139 | + | h.seed("open", domain.VisibilityPublic, "README.md") | |
| 140 | + | h.seed("secret-sauce", domain.VisibilityPrivate, "README.md") | |
| 141 | + | ||
| 142 | + | status, _, home := h.get("/") | |
| 143 | + | require.Equal(t, 200, status) | |
| 144 | + | assert.Contains(t, home, "open") | |
| 145 | + | assert.NotContains(t, home, "secret-sauce") | |
| 146 | + | ||
| 147 | + | _, _, missingBody := h.get("/never-existed") | |
| 148 | + | for _, p := range []string{"/secret-sauce", "/secret-sauce/", "/secret-sauce/refs", "/secret-sauce/tree/main", "/secret-sauce.git"} { | |
| 149 | + | status, hdr, body := h.get(p) | |
| 150 | + | assert.Equal(t, 404, status, p) | |
| 151 | + | assert.NotContains(t, body, "secret-sauce", p) | |
| 152 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |
| 153 | + | _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1)) | |
| 154 | + | assert.Equal(t, normalise(missing), normalise(missingBody), "%s is the page of a missing repository", p) | |
| 155 | + | } | |
| 156 | + | // The open repository's pages do not list it either. | |
| 157 | + | _, _, summary := h.get("/open") | |
| 158 | + | assert.NotContains(t, summary, "secret-sauce") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // AC4, end to end: a README that attacks the page is neutralised. | |
| 162 | + | func TestWebReadmeIsNeutralised(t *testing.T) { | |
| 163 | + | h := newHarness(t, harnessOpts{}) | |
| 164 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | wt := h.t.TempDir() | |
| 167 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |
| 168 | + | h.writeFile(wt, "README.md", []byte("# <script>alert(1)</script>Evil\n\n"+ | |
| 169 | + | "<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n\n\n\n"+ | |
| 170 | + | "<svg onload=alert(1)></svg>\n\n[ok](docs/a.md)\n")) | |
| 171 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/evil.git"), "main") | |
| 172 | + | ||
| 173 | + | status, _, body := h.get("/evil") | |
| 174 | + | require.Equal(t, 200, status) | |
| 175 | + | for _, bad := range []string{"alert(1)</script", "onerror", "javascript:", "evil.example", "<svg", "<img"} { | |
| 176 | + | assert.NotContains(t, body, bad) | |
| 177 | + | } | |
| 178 | + | assert.Contains(t, body, `href="/evil/blob/main/docs/a.md"`) | |
| 179 | + | assertNoInline(t, body) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | var ( | |
| 183 | + | reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`) | |
| 184 | + | reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`) | |
| 185 | + | reStyleTag = regexp.MustCompile(`(?i)<style\b`) | |
| 186 | + | reImport = regexp.MustCompile(`(?i)@import|url\(`) | |
| 187 | + | ) | |
| 188 | + | ||
| 189 | + | // assertNoInline checks the page has no inline script, style element or | |
| 190 | + | // style attribute. | |
| 191 | + | func assertNoInline(t *testing.T, body string) { | |
| 192 | + | t.Helper() | |
| 193 | + | for _, m := range reInlineScript.FindAllString(body, -1) { | |
| 194 | + | assert.Contains(t, m, "src=", "inline script: %s", m) | |
| 195 | + | } | |
| 196 | + | assert.False(t, reStyleAttr.MatchString(body), "style attribute") | |
| 197 | + | assert.False(t, reStyleTag.MatchString(body), "style element") | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // AC6: the CSP forbids inline scripts and styles, nothing is loaded from | |
| 201 | + | // another origin, no page has an inline script or a style attribute, and | |
| 202 | + | // every page carries the security headers. | |
| 203 | + | func TestWebContentSecurity(t *testing.T) { | |
| 204 | + | h := newHarness(t, harnessOpts{}) | |
| 205 | + | h.webRepo("demo") | |
| 206 | + | h.seed("quiet", domain.VisibilityPublic) | |
| 207 | + | ||
| 208 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |
| 209 | + | for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} { | |
| 210 | + | status, hdr, body := h.get(p) | |
| 211 | + | assert.Contains(t, []int{200, 404}, status, p) | |
| 212 | + | assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p) | |
| 213 | + | assert.NotContains(t, hdr.Get("Content-Security-Policy"), "unsafe-inline") | |
| 214 | + | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p) | |
| 215 | + | assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p) | |
| 216 | + | assert.Equal(t, "same-origin", hdr.Get("Referrer-Policy"), p) | |
| 217 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |
| 218 | + | assertNoInline(t, body) | |
| 219 | + | ||
| 220 | + | // Everything the page loads is a path on this origin. | |
| 221 | + | for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) { | |
| 222 | + | assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1]) | |
| 223 | + | } | |
| 224 | + | assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p) | |
| 225 | + | assert.False(t, reImport.MatchString(body), p) | |
| 226 | + | ||
| 227 | + | // Every static asset the page uses is served, immutable, from here. | |
| 228 | + | for _, m := range regexp.MustCompile(`"(/-/static/[^"]+)"`).FindAllStringSubmatch(body, -1) { | |
| 229 | + | st, ah, ab := h.get(m[1]) | |
| 230 | + | assert.Equal(t, 200, st, m[1]) | |
| 231 | + | assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1]) | |
| 232 | + | assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1]) | |
| 233 | + | assert.NotEmpty(t, ab) | |
| 234 | + | assert.False(t, reImport.MatchString(ab) && strings.HasSuffix(m[1], ".css"), "%s imports or loads a URL", m[1]) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // The footer links to the source (AGPL §13). | |
| 239 | + | _, _, body := h.get("/") | |
| 240 | + | assert.Contains(t, body, `<a href="http://example.test/ermite"`) | |
| 241 | + | ||
| 242 | + | // Unknown, stale or traversing asset names are 404s. | |
| 243 | + | for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} { | |
| 244 | + | st, _, _ := h.get(p) | |
| 245 | + | assert.Equal(t, 404, st, p) | |
| 246 | + | } | |
| 247 | + | } | |
| @@ -0,0 +1,247 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "flag" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "os" | |||
| 10 | + | "path/filepath" | |||
| 11 | + | "regexp" | |||
| 12 | + | "strings" | |||
| 13 | + | "testing" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite/internal/app" | |||
| 19 | + | "bnema.dev/ermite/internal/domain" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | var updateGolden = flag.Bool("update", false, "rewrite the golden files of the web tests") | |||
| 23 | + | ||||
| 24 | + | // webFixtureReadme exercises the Markdown policy and the outline. | |||
| 25 | + | const webFixtureReadme = "# Demo\n\nA small *demo* with a [guide](docs/guide.md) and [the site](https://example.org/x).\n\n" + | |||
| 26 | + | "## Install\n\n```sh\ngo install example.org/demo@latest\n```\n\n## Usage\n\n| a | b |\n|---|--:|\n| 1 | 2 |\n" | |||
| 27 | + | ||||
| 28 | + | // webRepo is the repository of the golden pages: public, with a README, two | |||
| 29 | + | // branches (one with a slash), two tags and a description. | |||
| 30 | + | func (h *harness) webRepo(name string) { | |||
| 31 | + | h.t.Helper() | |||
| 32 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{ | |||
| 33 | + | Name: h.name(name), Description: "A demo repository", Visibility: domain.VisibilityPublic, | |||
| 34 | + | }) | |||
| 35 | + | require.NoError(h.t, err) | |||
| 36 | + | wt := h.t.TempDir() | |||
| 37 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |||
| 38 | + | commit := func(date, msg string, files map[string]string) { | |||
| 39 | + | for p, c := range files { | |||
| 40 | + | require.NoError(h.t, os.MkdirAll(filepath.Dir(filepath.Join(wt, p)), 0o755)) | |||
| 41 | + | require.NoError(h.t, os.WriteFile(filepath.Join(wt, p), []byte(c), 0o644)) | |||
| 42 | + | h.mustGit(wt, "add", p) | |||
| 43 | + | } | |||
| 44 | + | env := []string{"GIT_AUTHOR_DATE=" + date, "GIT_COMMITTER_DATE=" + date} | |||
| 45 | + | out, err := h.gitEnv(env, wt, "commit", "-q", "-m", msg) | |||
| 46 | + | require.NoError(h.t, err, out) | |||
| 47 | + | } | |||
| 48 | + | commit("2026-01-01T10:00:00Z", "first commit", map[string]string{"README.md": webFixtureReadme, "docs/guide.md": "# Guide\n"}) | |||
| 49 | + | h.mustGit(wt, "tag", "v0.1.0") | |||
| 50 | + | commit("2026-01-02T10:00:00Z", "add main.go", map[string]string{"main.go": "package main\n"}) | |||
| 51 | + | h.mustGit(wt, "tag", "-a", "-m", "release", "v0.2.0") | |||
| 52 | + | h.mustGit(wt, "checkout", "-q", "-b", "feature/x") | |||
| 53 | + | commit("2026-01-03T10:00:00Z", "work on x", map[string]string{"x.txt": "x\n"}) | |||
| 54 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/"+name+".git"), "main", "feature/x", "--tags") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | // get does a GET and returns the status, the headers and the body. | |||
| 58 | + | func (h *harness) get(path string) (int, http.Header, string) { | |||
| 59 | + | h.t.Helper() | |||
| 60 | + | resp, err := http.Get(h.url(path)) | |||
| 61 | + | require.NoError(h.t, err) | |||
| 62 | + | defer func() { _ = resp.Body.Close() }() | |||
| 63 | + | b, err := io.ReadAll(resp.Body) | |||
| 64 | + | require.NoError(h.t, err) | |||
| 65 | + | return resp.StatusCode, resp.Header, string(b) | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | var ( | |||
| 69 | + | reSHA40 = regexp.MustCompile(`\b[0-9a-f]{40}\b`) | |||
| 70 | + | reSHA7 = regexp.MustCompile(`\b[0-9a-f]{7}\b`) | |||
| 71 | + | reAsset = regexp.MustCompile(`(/-/static/[a-z]+/[a-z.-]+?)\.[0-9a-f]{12}(\.\w+)`) | |||
| 72 | + | reAgo = regexp.MustCompile(`(\bnow\b|\d+(m|h|d|mo|y) ago)`) | |||
| 73 | + | reStamp = regexp.MustCompile(`\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}Z| UTC)?`) | |||
| 74 | + | reReqID = regexp.MustCompile(`request id: [0-9a-f]{8}`) | |||
| 75 | + | reSpaces = regexp.MustCompile(`[ \t]+\n|\n[ \t]*\n+`) | |||
| 76 | + | reMarkers = regexp.MustCompile(`>\s*<`) | |||
| 77 | + | ) | |||
| 78 | + | ||||
| 79 | + | // normalise makes a page comparable between runs: it hides what depends on | |||
| 80 | + | // the clock and on the commit ids, which hold the commit times. | |||
| 81 | + | func normalise(s string) string { | |||
| 82 | + | s = reAsset.ReplaceAllString(s, "$1.HASH$2") | |||
| 83 | + | s = reSHA40.ReplaceAllString(s, "SHA40") | |||
| 84 | + | s = reSHA7.ReplaceAllString(s, "SHA7") | |||
| 85 | + | s = reAgo.ReplaceAllString(s, "AGO") | |||
| 86 | + | s = reStamp.ReplaceAllString(s, "STAMP") | |||
| 87 | + | s = reReqID.ReplaceAllString(s, "request id: ID") | |||
| 88 | + | s = reSpaces.ReplaceAllString(s, "\n") | |||
| 89 | + | s = reMarkers.ReplaceAllString(s, ">\n<") | |||
| 90 | + | return strings.TrimSpace(s) + "\n" | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func golden(t *testing.T, name, got string) { | |||
| 94 | + | t.Helper() | |||
| 95 | + | path := filepath.Join("testdata", "web", name+".html") | |||
| 96 | + | if *updateGolden { | |||
| 97 | + | require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) | |||
| 98 | + | require.NoError(t, os.WriteFile(path, []byte(got), 0o644)) | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | want, err := os.ReadFile(path) | |||
| 102 | + | require.NoError(t, err, "run: go test ./cmd/ermite -run TestWebGolden -update") | |||
| 103 | + | assert.Equal(t, string(want), got, name) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | // AC1: the pages render for a fixture repository with the structure of the | |||
| 107 | + | // mockup; the HTML is compared after normalising times and ids. | |||
| 108 | + | func TestWebGolden(t *testing.T) { | |||
| 109 | + | h := newHarness(t, harnessOpts{}) | |||
| 110 | + | h.webRepo("demo") | |||
| 111 | + | h.seed("quiet", domain.VisibilityPublic) // empty repository | |||
| 112 | + | ||||
| 113 | + | for _, tt := range []struct { | |||
| 114 | + | name, path string | |||
| 115 | + | status int | |||
| 116 | + | }{ | |||
| 117 | + | {"home", "/", 200}, | |||
| 118 | + | {"summary", "/demo", 200}, | |||
| 119 | + | {"refs", "/demo/refs", 200}, | |||
| 120 | + | {"empty", "/quiet", 200}, | |||
| 121 | + | {"not-found", "/nope", 404}, | |||
| 122 | + | {"not-found-page", "/demo/tree/main", 404}, | |||
| 123 | + | } { | |||
| 124 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 125 | + | status, hdr, body := h.get(tt.path) | |||
| 126 | + | require.Equal(t, tt.status, status) | |||
| 127 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control")) | |||
| 128 | + | assert.Contains(t, hdr.Get("Content-Type"), "text/html") | |||
| 129 | + | golden(t, tt.name, normalise(body)) | |||
| 130 | + | }) | |||
| 131 | + | } | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // AC2: a private repository is invisible on the web: not in the home list, | |||
| 135 | + | // not in the ":" targets, and its URLs give the same 404 page as a missing | |||
| 136 | + | // repository. | |||
| 137 | + | func TestWebPrivateRepoIsInvisible(t *testing.T) { | |||
| 138 | + | h := newHarness(t, harnessOpts{}) | |||
| 139 | + | h.seed("open", domain.VisibilityPublic, "README.md") | |||
| 140 | + | h.seed("secret-sauce", domain.VisibilityPrivate, "README.md") | |||
| 141 | + | ||||
| 142 | + | status, _, home := h.get("/") | |||
| 143 | + | require.Equal(t, 200, status) | |||
| 144 | + | assert.Contains(t, home, "open") | |||
| 145 | + | assert.NotContains(t, home, "secret-sauce") | |||
| 146 | + | ||||
| 147 | + | _, _, missingBody := h.get("/never-existed") | |||
| 148 | + | for _, p := range []string{"/secret-sauce", "/secret-sauce/", "/secret-sauce/refs", "/secret-sauce/tree/main", "/secret-sauce.git"} { | |||
| 149 | + | status, hdr, body := h.get(p) | |||
| 150 | + | assert.Equal(t, 404, status, p) | |||
| 151 | + | assert.NotContains(t, body, "secret-sauce", p) | |||
| 152 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |||
| 153 | + | _, _, missing := h.get(strings.Replace(p, "secret-sauce", "never-existed", 1)) | |||
| 154 | + | assert.Equal(t, normalise(missing), normalise(missingBody), "%s is the page of a missing repository", p) | |||
| 155 | + | } | |||
| 156 | + | // The open repository's pages do not list it either. | |||
| 157 | + | _, _, summary := h.get("/open") | |||
| 158 | + | assert.NotContains(t, summary, "secret-sauce") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | // AC4, end to end: a README that attacks the page is neutralised. | |||
| 162 | + | func TestWebReadmeIsNeutralised(t *testing.T) { | |||
| 163 | + | h := newHarness(t, harnessOpts{}) | |||
| 164 | + | _, err := h.forge.Repos.Create(h.ctx, domain.Root(), app.CreateParams{Name: h.name("evil"), Visibility: domain.VisibilityPublic}) | |||
| 165 | + | require.NoError(t, err) | |||
| 166 | + | wt := h.t.TempDir() | |||
| 167 | + | h.mustGit(wt, "init", "-q", "--initial-branch=main") | |||
| 168 | + | h.writeFile(wt, "README.md", []byte("# <script>alert(1)</script>Evil\n\n"+ | |||
| 169 | + | "<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n\n\n\n"+ | |||
| 170 | + | "<svg onload=alert(1)></svg>\n\n[ok](docs/a.md)\n")) | |||
| 171 | + | h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/evil.git"), "main") | |||
| 172 | + | ||||
| 173 | + | status, _, body := h.get("/evil") | |||
| 174 | + | require.Equal(t, 200, status) | |||
| 175 | + | for _, bad := range []string{"alert(1)</script", "onerror", "javascript:", "evil.example", "<svg", "<img"} { | |||
| 176 | + | assert.NotContains(t, body, bad) | |||
| 177 | + | } | |||
| 178 | + | assert.Contains(t, body, `href="/evil/blob/main/docs/a.md"`) | |||
| 179 | + | assertNoInline(t, body) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | var ( | |||
| 183 | + | reInlineScript = regexp.MustCompile(`(?is)<script\b[^>]*>`) | |||
| 184 | + | reStyleAttr = regexp.MustCompile(`(?i)\sstyle\s*=`) | |||
| 185 | + | reStyleTag = regexp.MustCompile(`(?i)<style\b`) | |||
| 186 | + | reImport = regexp.MustCompile(`(?i)@import|url\(`) | |||
| 187 | + | ) | |||
| 188 | + | ||||
| 189 | + | // assertNoInline checks the page has no inline script, style element or | |||
| 190 | + | // style attribute. | |||
| 191 | + | func assertNoInline(t *testing.T, body string) { | |||
| 192 | + | t.Helper() | |||
| 193 | + | for _, m := range reInlineScript.FindAllString(body, -1) { | |||
| 194 | + | assert.Contains(t, m, "src=", "inline script: %s", m) | |||
| 195 | + | } | |||
| 196 | + | assert.False(t, reStyleAttr.MatchString(body), "style attribute") | |||
| 197 | + | assert.False(t, reStyleTag.MatchString(body), "style element") | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | // AC6: the CSP forbids inline scripts and styles, nothing is loaded from | |||
| 201 | + | // another origin, no page has an inline script or a style attribute, and | |||
| 202 | + | // every page carries the security headers. | |||
| 203 | + | func TestWebContentSecurity(t *testing.T) { | |||
| 204 | + | h := newHarness(t, harnessOpts{}) | |||
| 205 | + | h.webRepo("demo") | |||
| 206 | + | h.seed("quiet", domain.VisibilityPublic) | |||
| 207 | + | ||||
| 208 | + | const csp = "default-src 'self'; script-src 'self' 'unsafe-eval'; frame-ancestors 'none'" | |||
| 209 | + | for _, p := range []string{"/", "/demo", "/demo/refs", "/quiet", "/nope", "/demo/commits/main"} { | |||
| 210 | + | status, hdr, body := h.get(p) | |||
| 211 | + | assert.Contains(t, []int{200, 404}, status, p) | |||
| 212 | + | assert.Equal(t, csp, hdr.Get("Content-Security-Policy"), p) | |||
| 213 | + | assert.NotContains(t, hdr.Get("Content-Security-Policy"), "unsafe-inline") | |||
| 214 | + | assert.Equal(t, "DENY", hdr.Get("X-Frame-Options"), p) | |||
| 215 | + | assert.Equal(t, "nosniff", hdr.Get("X-Content-Type-Options"), p) | |||
| 216 | + | assert.Equal(t, "same-origin", hdr.Get("Referrer-Policy"), p) | |||
| 217 | + | assert.Equal(t, "no-cache", hdr.Get("Cache-Control"), p) | |||
| 218 | + | assertNoInline(t, body) | |||
| 219 | + | ||||
| 220 | + | // Everything the page loads is a path on this origin. | |||
| 221 | + | for _, m := range regexp.MustCompile(`<(?:script|link)\b[^>]*\b(?:src|href)="([^"]*)"`).FindAllStringSubmatch(body, -1) { | |||
| 222 | + | assert.True(t, strings.HasPrefix(m[1], "/-/static/"), "%s loads %s", p, m[1]) | |||
| 223 | + | } | |||
| 224 | + | assert.NotRegexp(t, `(?i)\b(?:src|action)="(?:https?:)?//`, body, p) | |||
| 225 | + | assert.False(t, reImport.MatchString(body), p) | |||
| 226 | + | ||||
| 227 | + | // Every static asset the page uses is served, immutable, from here. | |||
| 228 | + | for _, m := range regexp.MustCompile(`"(/-/static/[^"]+)"`).FindAllStringSubmatch(body, -1) { | |||
| 229 | + | st, ah, ab := h.get(m[1]) | |||
| 230 | + | assert.Equal(t, 200, st, m[1]) | |||
| 231 | + | assert.Equal(t, "public, max-age=31536000, immutable", ah.Get("Cache-Control"), m[1]) | |||
| 232 | + | assert.Equal(t, "nosniff", ah.Get("X-Content-Type-Options"), m[1]) | |||
| 233 | + | assert.NotEmpty(t, ab) | |||
| 234 | + | assert.False(t, reImport.MatchString(ab) && strings.HasSuffix(m[1], ".css"), "%s imports or loads a URL", m[1]) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | // The footer links to the source (AGPL §13). | |||
| 239 | + | _, _, body := h.get("/") | |||
| 240 | + | assert.Contains(t, body, `<a href="http://example.test/ermite"`) | |||
| 241 | + | ||||
| 242 | + | // Unknown, stale or traversing asset names are 404s. | |||
| 243 | + | for _, p := range []string{"/-/static/css/ermite.000000000000.css", "/-/static/css/ermite.css", "/-/static/", "/-/static/../x", "/-/static/js/"} { | |||
| 244 | + | st, _, _ := h.get(p) | |||
| 245 | + | assert.Equal(t, 404, st, p) | |||
| 246 | + | } | |||
| 247 | + | } | |||
A scripts/templ-drift.sh +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Fails when the committed *_templ.go files differ from what | |
| 3 | + | # `go tool templ generate` produces from the .templ sources. | |
| 4 | + | set -euo pipefail | |
| 5 | + | ||
| 6 | + | cd "$(dirname "$0")/.." | |
| 7 | + | ||
| 8 | + | go tool templ generate >/dev/null | |
| 9 | + | if ! git diff --exit-code --stat -- '*_templ.go'; then | |
| 10 | + | echo "templ output is stale: run 'go tool templ generate' and commit the result" >&2 | |
| 11 | + | exit 1 | |
| 12 | + | fi | |
| 13 | + | if [ -n "$(git ls-files --others --exclude-standard -- '*_templ.go')" ]; then | |
| 14 | + | echo "templ generated files that are not committed:" >&2 | |
| 15 | + | git ls-files --others --exclude-standard -- '*_templ.go' >&2 | |
| 16 | + | exit 1 | |
| 17 | + | fi | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | #!/usr/bin/env bash | |||
| 2 | + | # Fails when the committed *_templ.go files differ from what | |||
| 3 | + | # `go tool templ generate` produces from the .templ sources. | |||
| 4 | + | set -euo pipefail | |||
| 5 | + | ||||
| 6 | + | cd "$(dirname "$0")/.." | |||
| 7 | + | ||||
| 8 | + | go tool templ generate >/dev/null | |||
| 9 | + | if ! git diff --exit-code --stat -- '*_templ.go'; then | |||
| 10 | + | echo "templ output is stale: run 'go tool templ generate' and commit the result" >&2 | |||
| 11 | + | exit 1 | |||
| 12 | + | fi | |||
| 13 | + | if [ -n "$(git ls-files --others --exclude-standard -- '*_templ.go')" ]; then | |||
| 14 | + | echo "templ generated files that are not committed:" >&2 | |||
| 15 | + | git ls-files --others --exclude-standard -- '*_templ.go' >&2 | |||
| 16 | + | exit 1 | |||
| 17 | + | fi | |||