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/ui_test.go +95−1
| @@ -39,11 +39,34 @@ func TestRepositoryPagesShareTheTabHeader(t *testing.T) { | |||
| 39 | 39 | if code != 200 { | |
| 40 | 40 | t.Fatalf("%s = %d", path, code) | |
| 41 | 41 | } | |
| 42 | for _, want := range []string{">Overview<", ">Activity<", ">Code<", ">Issues<", ">Changes<", ">Releases<", ">Runs<", ">Locks<"} { | ||
| 42 | for _, want := range []string{`class="repotab`, ">Activity<", ">Code<", ">Issues<", ">Changes<", ">Releases<", ">Runs<", ">Locks<"} { | ||
| 43 | 43 | if !strings.Contains(body, want) { | |
| 44 | 44 | t.Fatalf("%s is missing the %s tab:\n%s", path, want, body) | |
| 45 | 45 | } | |
| 46 | 46 | } | |
| 47 | if !strings.Contains(body, `class="page-head"`) { | ||
| 48 | t.Fatalf("%s is missing the shared page header:\n%s", path, body) | ||
| 49 | } | ||
| 50 | } | ||
| 51 | } | ||
| 52 | |||
| 53 | func TestRepoTabShowsNameAndVisibility(t *testing.T) { | ||
| 54 | t.Parallel() | ||
| 55 | f := newFixture(t) | ||
| 56 | |||
| 57 | // A new repository is private until its ACL default is raised to read. | ||
| 58 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 59 | resp, body := f.browserGet(t, "/r/"+f.repo, cookie) | ||
| 60 | if resp.StatusCode != 200 || !strings.Contains(body, `class="repotab active"`) || | ||
| 61 | !strings.Contains(body, `title="Private repository"`) || !strings.Contains(body, ">demo</a>") { | ||
| 62 | t.Fatalf("private repo tab = %d:\n%s", resp.StatusCode, body) | ||
| 63 | } | ||
| 64 | |||
| 65 | f.publish(f.repo) | ||
| 66 | _, body = f.get("/r/" + f.repo + "/code") | ||
| 67 | if !strings.Contains(body, `title="Public repository"`) || | ||
| 68 | !strings.Contains(body, `aria-label="demo (public repository)"`) { | ||
| 69 | t.Fatalf("public repo tab does not show the globe state:\n%s", body) | ||
| 47 | 70 | } | |
| 48 | 71 | } | |
| 49 | 72 | ||
| @@ -114,6 +137,77 @@ func TestOverviewAndActivityPages(t *testing.T) { | |||
| 114 | 137 | } | |
| 115 | 138 | } | |
| 116 | 139 | ||
| 140 | func TestCodeBrowserTreeAndFragments(t *testing.T) { | ||
| 141 | t.Parallel() | ||
| 142 | f := newFixture(t) | ||
| 143 | f.publish(f.repo) | ||
| 144 | work := workRepo(t, map[string]string{ | ||
| 145 | "README.md": "# Code browser\n", | ||
| 146 | "main.go": "package main\n", | ||
| 147 | "internal/run.go": "package internal\n", | ||
| 148 | }) | ||
| 149 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 150 | t.Fatalf("push: %s", errs) | ||
| 151 | } | ||
| 152 | |||
| 153 | code, body := f.get("/r/" + f.repo + "/code") | ||
| 154 | if code != 200 { | ||
| 155 | t.Fatalf("code browser = %d", code) | ||
| 156 | } | ||
| 157 | for _, want := range []string{ | ||
| 158 | `id="file-tree"`, `id="code-main"`, `data-path="internal/run.go"`, | ||
| 159 | `data-path="README.md"`, "tree-file active", "internal", | ||
| 160 | } { | ||
| 161 | if !strings.Contains(body, want) { | ||
| 162 | t.Fatalf("code browser does not contain %q:\n%s", want, body) | ||
| 163 | } | ||
| 164 | } | ||
| 165 | |||
| 166 | // A file click is a fragment: the tree stays put and the main pane is replaced. | ||
| 167 | resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/blob/main/internal/run.go", "") | ||
| 168 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="code-main"`) || | ||
| 169 | !strings.Contains(fragment, `data-mode="blob"`) || | ||
| 170 | strings.Contains(fragment, "<!doctype html>") || | ||
| 171 | strings.Contains(fragment, `id="file-tree"`) || | ||
| 172 | !strings.Contains(fragment, `class="chroma"`) { | ||
| 173 | t.Fatalf("blob fragment = %d:\n%s", resp.StatusCode, fragment) | ||
| 174 | } | ||
| 175 | |||
| 176 | // A directory click replaces the main pane with a listing whose child links | ||
| 177 | // carry the directory path rather than only the leaf name. | ||
| 178 | resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/tree/main/internal", "") | ||
| 179 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) || | ||
| 180 | !strings.Contains(fragment, "/r/"+f.repo+"/blob/main/internal/run.go") { | ||
| 181 | t.Fatalf("tree fragment = %d:\n%s", resp.StatusCode, fragment) | ||
| 182 | } | ||
| 183 | |||
| 184 | // The branch crumb returns to the Code overview, not a second directory | ||
| 185 | // listing inside the main pane. | ||
| 186 | resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/code?ref=main", "") | ||
| 187 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="overview"`) || | ||
| 188 | !strings.Contains(fragment, "<h1>Code browser</h1>") { | ||
| 189 | t.Fatalf("branch crumb fragment = %d:\n%s", resp.StatusCode, fragment) | ||
| 190 | } | ||
| 191 | if !strings.Contains(body, `href="/r/`+f.repo+`/code?ref=main"`) { | ||
| 192 | t.Fatalf("branch crumb does not target the code overview:\n%s", body) | ||
| 193 | } | ||
| 194 | |||
| 195 | // The branch root still has its own directory URL, used by the ".." row. | ||
| 196 | resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/tree/main", "") | ||
| 197 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) || | ||
| 198 | !strings.Contains(fragment, "main.go") { | ||
| 199 | t.Fatalf("root tree fragment = %d:\n%s", resp.StatusCode, fragment) | ||
| 200 | } | ||
| 201 | |||
| 202 | // A direct directory visit renders the same shell, with the folder open in | ||
| 203 | // the tree and a link back to its parent. | ||
| 204 | code, body = f.get("/r/" + f.repo + "/tree/main/internal") | ||
| 205 | if code != 200 || !strings.Contains(body, `id="file-tree"`) || | ||
| 206 | !strings.Contains(body, "internal/run.go") || !strings.Contains(body, "..") { | ||
| 207 | t.Fatalf("directory page = %d:\n%s", code, body) | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 117 | 211 | func TestSourceBlobIsHighlightedAndEscaped(t *testing.T) { | |
| 118 | 212 | t.Parallel() | |
| 119 | 213 | f := newFixture(t) | |