vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Turn the code tab into an editor-style file browser
a464f8927bfb3edcf170694f120dc9b3f68320da · Keenan Nemetz · 15d ago · parent f09ef39e
Turn the code tab into an editor-style file browser - Add a persistent nested file tree with HTMX file and folder swaps, active rows, expanded ancestors, and deep-link support. - Share one page header across every repository tab: title and summary on the left, tab actions grouped on the right. - Make the repository name the first tab and show visibility with a lock or globe icon derived from the ACL. - Open markdown as syntax-highlighted source by default, with an explicit full-width preview toggle. - Point the branch breadcrumb at the Code overview instead of a second root directory listing. - Move contributor-branch CI approval from the code browser to the Runs tab. - Extend UI tests for the file tree, fragments, repo visibility, markdown source/preview, shared headers, and the moved CI control.
server/web/templates/code.html +128−95
| @@ -2,26 +2,31 @@ | |||
| 2 | 2 | {{template "header" .}} | |
| 3 | 3 | {{template "repohead" (nav .Name "code")}} | |
| 4 | 4 | ||
| 5 | <div class="repo-sub"> | ||
| 6 | <form method="get" action="/r/{{.Name}}/code" class="inline"> | ||
| 7 | <select name="ref" data-autosubmit aria-label="Branch"> | ||
| 8 | {{range .Branches}}<option value="{{.}}"{{if eq . $.Ref}} selected{{end}}>{{.}}</option>{{end}} | ||
| 9 | </select> | ||
| 10 | <noscript><button class="secondary" type="submit">Go</button></noscript> | ||
| 11 | </form> | ||
| 12 | <span class="item">{{.Count}} commit(s)</span> | ||
| 13 | <details class="clone"> | ||
| 14 | <summary>{{icon "copy"}}Clone</summary> | ||
| 15 | <div class="clone-pop"> | ||
| 16 | <p class="muted">Read only, no credentials:</p> | ||
| 17 | <code>{{.CloneURL}}</code> | ||
| 18 | <p class="muted">Write over SSH with a registered key:</p> | ||
| 19 | <code>git clone {{.SSHURL}}</code> | ||
| 20 | <p><button class="secondary" type="button" data-copy="{{.CloneURL}}">{{icon "copy"}}Copy HTTPS URL</button></p> | ||
| 21 | </div> | ||
| 22 | </details> | ||
| 23 | <a class="button secondary" href="/r/{{.Name}}/commits/{{esc .Ref}}">{{icon "history"}}History</a> | ||
| 24 | {{if .Site}}<a class="item" href="{{.Site}}">Site</a>{{end}} | ||
| 5 | <div class="page-head"> | ||
| 6 | <div class="page-head-main"> | ||
| 7 | <h1>Code</h1> | ||
| 8 | <p class="page-sub">{{.Count}} commit(s) on <code>{{.Ref}}</code></p> | ||
| 9 | </div> | ||
| 10 | <div class="page-head-actions"> | ||
| 11 | <form method="get" action="/r/{{.Name}}/code" class="inline"> | ||
| 12 | <select name="ref" data-autosubmit aria-label="Branch"> | ||
| 13 | {{range .Branches}}<option value="{{.}}"{{if eq . $.Ref}} selected{{end}}>{{.}}</option>{{end}} | ||
| 14 | </select> | ||
| 15 | <noscript><button class="secondary" type="submit">Go</button></noscript> | ||
| 16 | </form> | ||
| 17 | <details class="clone"> | ||
| 18 | <summary>{{icon "copy"}}Clone</summary> | ||
| 19 | <div class="clone-pop"> | ||
| 20 | <p class="muted">Read only, no credentials:</p> | ||
| 21 | <code>{{.CloneURL}}</code> | ||
| 22 | <p class="muted">Write over SSH with a registered key:</p> | ||
| 23 | <code>git clone {{.SSHURL}}</code> | ||
| 24 | <p><button class="secondary" type="button" data-copy="{{.CloneURL}}">{{icon "copy"}}Copy HTTPS URL</button></p> | ||
| 25 | </div> | ||
| 26 | </details> | ||
| 27 | <a class="button secondary" href="/r/{{.Name}}/commits/{{esc .Ref}}">{{icon "history"}}History</a> | ||
| 28 | {{if .Site}}<a class="button secondary" href="{{.Site}}">{{icon "external-link"}}Site</a>{{end}} | ||
| 29 | </div> | ||
| 25 | 30 | </div> | |
| 26 | 31 | ||
| 27 | 32 | {{range .Latest}} | |
| @@ -32,83 +37,123 @@ | |||
| 32 | 37 | </div> | |
| 33 | 38 | {{end}} | |
| 34 | 39 | ||
| 35 | <h2>Files{{if .Ref}} <span class="muted">on {{.Ref}}</span>{{end}}</h2> | ||
| 36 | {{if .Entries}} | ||
| 37 | <div class="panel"> | ||
| 38 | <table> | ||
| 39 | <tbody> | ||
| 40 | {{range .Entries}} | ||
| 41 | <tr> | ||
| 40 | <div class="code-browser"> | ||
| 41 | {{template "code_tree" .}} | ||
| 42 | {{template "code_main" .}} | ||
| 43 | </div> | ||
| 44 | |||
| 45 | {{template "footer" .}} | ||
| 46 | {{end}} | ||
| 47 | |||
| 48 | {{define "code_tree"}} | ||
| 49 | <aside class="file-tree" id="file-tree" aria-label="Files"> | ||
| 50 | <div class="tree-head"> | ||
| 51 | <span>{{icon "folder-open"}}Files</span> | ||
| 52 | <span class="muted">{{.Ref}}</span> | ||
| 53 | </div> | ||
| 54 | {{if .Tree}} | ||
| 55 | <div class="tree-scroll"> | ||
| 56 | {{template "tree_nodes" (treeContext .Name .Ref .Tree)}} | ||
| 57 | </div> | ||
| 58 | {{if .TreeTruncated}}<p class="tree-note">Showing the first 4,000 files.</p>{{end}} | ||
| 59 | {{else}} | ||
| 60 | <p class="tree-empty muted">{{if .Commit}}No files on this branch.{{else}}Push a branch to browse it.{{end}}</p> | ||
| 61 | {{end}} | ||
| 62 | </aside> | ||
| 63 | {{end}} | ||
| 64 | |||
| 65 | {{define "tree_nodes"}} | ||
| 66 | <ul class="tree-list"> | ||
| 67 | {{range .Nodes}} | ||
| 68 | <li> | ||
| 42 | 69 | {{if eq .Type "tree"}} | |
| 43 | <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Name}}">{{.Name}}/</a></td> | ||
| 70 | <details class="tree-dir{{if .Active}} active{{end}}" data-path="{{.Path}}"{{if .Open}} open{{end}}> | ||
| 71 | <summary class="tree-row"> | ||
| 72 | <span class="tree-chevron">{{icon "chevron-right"}}</span> | ||
| 73 | <span class="tree-icon">{{icon "folder"}}</span> | ||
| 74 | <span class="tree-name" title="{{.Path}}">{{.Name}}</span> | ||
| 75 | </summary> | ||
| 76 | <div class="tree-children">{{template "tree_nodes" (treeContext $.Name $.Ref .Children)}}</div> | ||
| 77 | </details> | ||
| 44 | 78 | {{else}} | |
| 45 | <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{.Name}}">{{.Name}}</a></td> | ||
| 79 | <a class="tree-row tree-file{{if .Active}} active{{end}}" data-path="{{.Path}}" href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{pathurl .Path}}" hx-get="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{pathurl .Path}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true" title="{{.Path}}"> | ||
| 80 | <span class="tree-icon">{{icon "file"}}</span> | ||
| 81 | <span class="tree-name">{{.Name}}</span> | ||
| 82 | </a> | ||
| 46 | 83 | {{end}} | |
| 47 | </tr> | ||
| 84 | </li> | ||
| 48 | 85 | {{end}} | |
| 49 | </tbody> | ||
| 50 | </table> | ||
| 51 | </div> | ||
| 52 | {{else}} | ||
| 53 | <p class="muted">This repository is empty. Push a branch to fill it:</p> | ||
| 54 | <pre>git push origin HEAD:refs/heads/{{.Ref}}</pre> | ||
| 86 | </ul> | ||
| 55 | 87 | {{end}} | |
| 56 | 88 | ||
| 57 | {{if .Readme}} | ||
| 58 | <div class="filehead"> | ||
| 59 | <span>{{.ReadmeName}}</span> | ||
| 60 | <a href="/r/{{.Name}}/blob/{{esc .Ref}}/{{.ReadmeName}}">view source</a> | ||
| 61 | </div> | ||
| 62 | <article class="md readme">{{.Readme}}</article> | ||
| 89 | {{define "code_main"}} | ||
| 90 | <section id="code-main" class="code-main" data-path="{{.Path}}" data-mode="{{.Mode}}" data-ref="{{.Ref}}"> | ||
| 91 | {{if eq .Mode "blob"}}{{template "code_blob" .}} | ||
| 92 | {{else if eq .Mode "tree"}}{{template "code_dir" .}} | ||
| 93 | {{else}}{{template "code_overview" .}}{{end}} | ||
| 94 | </section> | ||
| 63 | 95 | {{end}} | |
| 64 | 96 | ||
| 65 | {{if .ContributorBranches}} | ||
| 66 | <h2>Contributor branches</h2> | ||
| 67 | <p class="muted">CI does not run for these branches until a maintainer allows the current commit.</p> | ||
| 68 | <div class="panel"> | ||
| 69 | <table> | ||
| 70 | <thead><tr><th>Branch</th><th>CI</th></tr></thead> | ||
| 71 | <tbody> | ||
| 72 | {{range .ContributorBranches}} | ||
| 73 | <tr> | ||
| 74 | <td><code>{{.}}</code></td> | ||
| 75 | <td> | ||
| 76 | {{if $.CanAllowCI}} | ||
| 77 | <form method="post" action="/r/{{$.Name}}/ci/allow"> | ||
| 78 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> | ||
| 79 | <input type="hidden" name="ref" value="refs/heads/{{.}}"> | ||
| 80 | <button type="submit">Allow CI for this tip</button> | ||
| 81 | </form> | ||
| 82 | {{else}}<span class="muted">waiting for a maintainer to allow it</span>{{end}} | ||
| 83 | </td> | ||
| 84 | </tr> | ||
| 85 | {{end}} | ||
| 86 | </tbody> | ||
| 87 | </table> | ||
| 97 | {{define "code_tabbar"}} | ||
| 98 | <div class="code-tabbar"> | ||
| 99 | <span class="code-tab-icon">{{if or (eq .Mode "tree") (and (eq .Mode "overview") (not .Readme))}}{{icon "folder"}}{{else if eq .Mode "blob"}}{{icon "file"}}{{else}}{{icon "book-open"}}{{end}}</span> | ||
| 100 | <nav class="code-crumbs" aria-label="Breadcrumb"> | ||
| 101 | <a href="/r/{{.Name}}/code?ref={{esc .Ref}}" hx-get="/r/{{.Name}}/code?ref={{esc .Ref}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">{{.Ref}}</a> | ||
| 102 | {{range $i, $c := .Crumbs}}<span class="sep">/</span>{{if eq (inc $i) (len $.Crumbs)}}<span class="current" title="{{$c.Path}}">{{$c.Name}}</span>{{else}}<a href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{pathurl $c.Path}}" hx-get="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{pathurl $c.Path}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">{{$c.Name}}</a>{{end}}{{end}} | ||
| 103 | {{if not .Crumbs}}<span class="sep">/</span><span class="current">Files</span>{{end}} | ||
| 104 | </nav> | ||
| 105 | <span class="code-tab-actions"> | ||
| 106 | {{if eq .Mode "blob"}} | ||
| 107 | {{if and .IsMarkdown (not .Truncated)}} | ||
| 108 | <a class="{{if not .Preview}}active{{end}}" href="/r/{{.Name}}/blob/{{esc .Ref}}/{{pathurl .Path}}" hx-get="/r/{{.Name}}/blob/{{esc .Ref}}/{{pathurl .Path}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">source</a> | ||
| 109 | <a class="{{if .Preview}}active{{end}}" href="/r/{{.Name}}/blob/{{esc .Ref}}/{{pathurl .Path}}?preview=1" hx-get="/r/{{.Name}}/blob/{{esc .Ref}}/{{pathurl .Path}}?preview=1" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">preview</a> | ||
| 110 | {{end}} | ||
| 111 | <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{pathurl .Path}}">raw</a> | ||
| 112 | <span class="muted">{{bytes .Size}}</span> | ||
| 113 | {{else if and (eq .Mode "overview") .Readme}} | ||
| 114 | <a href="/r/{{.Name}}/blob/{{esc .Ref}}/{{pathurl .ReadmeName}}">source</a> | ||
| 115 | {{else if .Entries}} | ||
| 116 | <span class="muted">{{len .Entries}} item(s)</span> | ||
| 117 | {{end}} | ||
| 118 | </span> | ||
| 88 | 119 | </div> | |
| 89 | 120 | {{end}} | |
| 90 | 121 | ||
| 91 | {{template "footer" .}} | ||
| 122 | {{define "code_overview"}} | ||
| 123 | {{if .Readme}} | ||
| 124 | {{template "code_tabbar" .}} | ||
| 125 | <article class="md readme">{{.Readme}}</article> | ||
| 126 | {{else if not .Commit}} | ||
| 127 | {{template "code_tabbar" .}} | ||
| 128 | <div class="card empty-code"> | ||
| 129 | <h2>Empty repository</h2> | ||
| 130 | <p class="muted">Nothing has been pushed yet. Add this repository and push a branch:</p> | ||
| 131 | <pre>git remote add origin {{.SSHURL}} | ||
| 132 | git push -u origin {{.Ref}}</pre> | ||
| 133 | </div> | ||
| 134 | {{else}} | ||
| 135 | {{template "code_dir" .}} | ||
| 136 | {{end}} | ||
| 92 | 137 | {{end}} | |
| 93 | 138 | ||
| 94 | {{define "tree"}} | ||
| 95 | {{template "header" .}} | ||
| 96 | {{template "repohead" (nav .Name "code")}} | ||
| 97 | <p class="crumbs"> | ||
| 98 | <a href="/r/{{.Name}}/code?ref={{esc .Ref}}">{{.Ref}}</a> | ||
| 99 | {{range .Crumbs}} / <a href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Path}}">{{.Name}}</a>{{end}} | ||
| 100 | </p> | ||
| 101 | {{if .Entries}} | ||
| 102 | <div class="panel"> | ||
| 139 | {{define "code_dir"}} | ||
| 140 | {{template "code_tabbar" .}} | ||
| 141 | {{if or .Entries .Path}} | ||
| 142 | <div class="panel dirlist"> | ||
| 103 | 143 | <table> | |
| 104 | 144 | <tbody> | |
| 145 | {{if .Path}} | ||
| 146 | <tr><td><a class="dir parent" href="{{if .Parent}}/r/{{$.Name}}/tree/{{esc $.Ref}}/{{pathurl .Parent}}{{else}}/r/{{$.Name}}/tree/{{esc $.Ref}}{{end}}" hx-get="{{if .Parent}}/r/{{$.Name}}/tree/{{esc $.Ref}}/{{pathurl .Parent}}{{else}}/r/{{$.Name}}/tree/{{esc $.Ref}}{{end}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">..</a></td></tr> | ||
| 147 | {{end}} | ||
| 105 | 148 | {{range .Entries}} | |
| 106 | 149 | <tr> | |
| 107 | {{if eq .Type "tree"}} | ||
| 108 | <td><a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{$.Path}}{{if $.Path}}/{{end}}{{.Name}}">{{.Name}}/</a></td> | ||
| 109 | {{else}} | ||
| 110 | <td><a href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{$.Path}}{{if $.Path}}/{{end}}{{.Name}}">{{.Name}}</a></td> | ||
| 111 | {{end}} | ||
| 150 | <td> | ||
| 151 | {{if eq .Type "tree"}} | ||
| 152 | <a class="dir" href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{pathurl (pathjoin $.Path .Name)}}" hx-get="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{pathurl (pathjoin $.Path .Name)}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">{{icon "folder"}}{{.Name}}/</a> | ||
| 153 | {{else}} | ||
| 154 | <a class="file" href="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{pathurl (pathjoin $.Path .Name)}}" hx-get="/r/{{$.Name}}/blob/{{esc $.Ref}}/{{pathurl (pathjoin $.Path .Name)}}" hx-target="#code-main" hx-swap="outerHTML" hx-push-url="true">{{icon "file"}}{{.Name}}</a> | ||
| 155 | {{end}} | ||
| 156 | </td> | ||
| 112 | 157 | </tr> | |
| 113 | 158 | {{end}} | |
| 114 | 159 | </tbody> | |
| @@ -117,25 +162,14 @@ | |||
| 117 | 162 | {{else}} | |
| 118 | 163 | <p class="muted">This directory is empty.</p> | |
| 119 | 164 | {{end}} | |
| 120 | {{template "footer" .}} | ||
| 121 | 165 | {{end}} | |
| 122 | 166 | ||
| 123 | {{define "blob"}} | ||
| 124 | {{template "header" .}} | ||
| 125 | {{template "repohead" (nav .Name "code")}} | ||
| 126 | <h1 class="path-title">{{.Path}}</h1> | ||
| 127 | <p class="crumbs"> | ||
| 128 | <a href="/r/{{.Name}}/code?ref={{esc .Ref}}">{{.Ref}}</a> | ||
| 129 | {{range .Crumbs}} / <a href="/r/{{$.Name}}/tree/{{esc $.Ref}}/{{.Path}}">{{.Name}}</a>{{end}} | ||
| 130 | <span class="crumb-actions"> | ||
| 131 | <a href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">raw</a> | ||
| 132 | <span class="muted">{{bytes .Size}}</span> | ||
| 133 | </span> | ||
| 134 | </p> | ||
| 167 | {{define "code_blob"}} | ||
| 168 | {{template "code_tabbar" .}} | ||
| 135 | 169 | {{if .Binary}} | |
| 136 | 170 | <div class="card"> | |
| 137 | 171 | <p class="muted">Binary file · {{bytes .Size}}. It is not shown inline.</p> | |
| 138 | <p><a class="button secondary" href="/r/{{.Name}}/raw/{{esc .Ref}}/{{.Path}}">Download</a></p> | ||
| 172 | <p><a class="button secondary" href="/r/{{.Name}}/raw/{{esc .Ref}}/{{pathurl .Path}}">Download</a></p> | ||
| 139 | 173 | </div> | |
| 140 | 174 | {{else}} | |
| 141 | 175 | {{if .Truncated}}<p class="muted">Showing the first 1 MiB.</p>{{end}} | |
| @@ -154,7 +188,6 @@ | |||
| 154 | 188 | </div> | |
| 155 | 189 | {{end}} | |
| 156 | 190 | {{end}} | |
| 157 | {{template "footer" .}} | ||
| 158 | 191 | {{end}} | |
| 159 | 192 | ||
| 160 | 193 | {{define "commits"}} | |