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/web.go +133−87
| @@ -73,7 +73,7 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto | |||
| 73 | 73 | "bytes": format.Bytes, | |
| 74 | 74 | "credid": identity.CredentialIDString, | |
| 75 | 75 | "who": w.who, | |
| 76 | "nav": repoNav, | ||
| 76 | "nav": w.repoNav, | ||
| 77 | 77 | "inc": func(i int) int { return i + 1 }, | |
| 78 | 78 | "initial": initial, | |
| 79 | 79 | "tone": tone, | |
| @@ -81,6 +81,10 @@ func New(repos *repo.Store, ix *index.Store, ident *identity.Store, rs *runs.Sto | |||
| 81 | 81 | "join": strings.Join, | |
| 82 | 82 | "tokenPicker": tokenPicker, | |
| 83 | 83 | "icon": icon, | |
| 84 | "treeContext": treeContext, | ||
| 85 | "pathurl": pathURL, | ||
| 86 | "pathjoin": joinPath, | ||
| 87 | "parentpath": parentPath, | ||
| 84 | 88 | }).ParseFS(templateFS, "templates/*.html") | |
| 85 | 89 | if err != nil { | |
| 86 | 90 | return nil, err | |
| @@ -106,13 +110,27 @@ func (w *Web) who(idOrName string) string { | |||
| 106 | 110 | return a.Label() | |
| 107 | 111 | } | |
| 108 | 112 | ||
| 109 | // repoNav is the repository header's data: which repository, and which tab is | ||
| 110 | // current. Templates call it as {{template "repohead" (nav .Name "issues")}}. | ||
| 111 | func repoNav(name, tab string) repoNavData { return repoNavData{Name: name, Tab: tab} } | ||
| 113 | // repoNav is the repository header's data: which repository, which tab is | ||
| 114 | // current, and whether anonymous readers can see it. Visibility comes from the | ||
| 115 | // same ACL rule the handlers use, so the lock/globe icon cannot disagree with | ||
| 116 | // whether an anonymous clone works. Templates call it as | ||
| 117 | // {{template "repohead" (nav .Name "issues")}}. | ||
| 118 | func (w *Web) repoNav(name, tab string) repoNavData { | ||
| 119 | n := repoNavData{Name: name, Tab: tab, Private: true} | ||
| 120 | m, err := w.Repos.Open(name) | ||
| 121 | if err != nil { | ||
| 122 | return n | ||
| 123 | } | ||
| 124 | if acl, err := m.ACL(); err == nil { | ||
| 125 | n.Private = !acl.Can("", access.Read) | ||
| 126 | } | ||
| 127 | return n | ||
| 128 | } | ||
| 112 | 129 | ||
| 113 | 130 | type repoNavData struct { | |
| 114 | Name string | ||
| 115 | Tab string | ||
| 131 | Name string | ||
| 132 | Tab string | ||
| 133 | Private bool | ||
| 116 | 134 | } | |
| 117 | 135 | ||
| 118 | 136 | // Page is embedded by every view. Exported so html/template can promote its | |
| @@ -207,33 +225,68 @@ func (w *Web) open(rw http.ResponseWriter, r *http.Request) (*repo.Meta, bool) { | |||
| 207 | 225 | return m, true | |
| 208 | 226 | } | |
| 209 | 227 | ||
| 210 | // Code is the file browser: the branch, its tree, and the README at the tip. | ||
| 211 | // The repository overview (Overview) is the page that summarises the project. | ||
| 228 | // Code is the file browser: a persistent tree beside the file it has open. The | ||
| 229 | // repository overview (Overview) is the page that summarises the project. | ||
| 212 | 230 | func (w *Web) Code(rw http.ResponseWriter, r *http.Request) { | |
| 213 | 231 | m, ok := w.open(rw, r) | |
| 214 | 232 | if !ok { | |
| 215 | 233 | return | |
| 216 | 234 | } | |
| 217 | g := m.Git() | ||
| 218 | 235 | ref := r.URL.Query().Get("ref") | |
| 219 | 236 | if ref == "" { | |
| 220 | 237 | ref = m.DefaultBranch | |
| 221 | 238 | } | |
| 239 | g := m.Git() | ||
| 222 | 240 | commit, _ := g.RevParse(ref) | |
| 223 | var entries []git.Entry | ||
| 224 | var latest []git.Commit | ||
| 225 | count := 0 | ||
| 241 | var readme template.HTML | ||
| 242 | readmeName := "" | ||
| 226 | 243 | if commit != "" { | |
| 227 | entries, _ = g.LsTree(commit) | ||
| 228 | latest, _ = g.Log(commit, 1) | ||
| 229 | count, _ = g.CountCommits(commit) | ||
| 230 | } | ||
| 231 | readme, readmeName := w.readmeAt(m, commit) | ||
| 232 | // A site is a branch, so the page can link to it whether or not the branch | ||
| 233 | // exists yet: pushing it makes the link true, and until then it 404s. | ||
| 234 | site := "" | ||
| 244 | readme, readmeName = w.readmeAt(m, commit) | ||
| 245 | } | ||
| 246 | // The README is what the main pane opens with, so it is also the tree row | ||
| 247 | // that should look current when no file has been selected yet. | ||
| 248 | full := !htmxRequest(r) | ||
| 249 | view := w.codeShell(r, m, ref, commit, readmeName, full) | ||
| 250 | view.Mode = "overview" | ||
| 251 | if commit != "" { | ||
| 252 | view.Entries, _ = g.LsTree(commit) | ||
| 253 | sortEntries(view.Entries) | ||
| 254 | view.Readme, view.ReadmeName = readme, readmeName | ||
| 255 | if readmeName != "" { | ||
| 256 | view.Path = readmeName | ||
| 257 | view.Crumbs = crumbs(readmeName) | ||
| 258 | } | ||
| 259 | } | ||
| 260 | if !full { | ||
| 261 | w.render(rw, "code_main", view, 0) | ||
| 262 | return | ||
| 263 | } | ||
| 264 | w.render(rw, "code", view, 0) | ||
| 265 | } | ||
| 266 | |||
| 267 | // codeShell gathers everything the full code page keeps outside the main pane: | ||
| 268 | // the branch list, the file tree, the latest commit, and contributor-branch | ||
| 269 | // state. HTMX fragment requests skip the work they will not render. | ||
| 270 | func (w *Web) codeShell(r *http.Request, m *repo.Meta, ref, commit, active string, full bool) codeView { | ||
| 271 | g := m.Git() | ||
| 272 | v := codeView{ | ||
| 273 | Page: w.page(r, m.Name+" · code"), | ||
| 274 | Name: m.Name, | ||
| 275 | Ref: ref, | ||
| 276 | Commit: commit, | ||
| 277 | CloneURL: cloneURL(r, m.Name), | ||
| 278 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 279 | } | ||
| 235 | 280 | if w.Cfg.PagesDomain != "" { | |
| 236 | site = "http://" + m.Name + "." + w.Cfg.PagesDomain | ||
| 281 | v.Site = "http://" + m.Name + "." + w.Cfg.PagesDomain | ||
| 282 | } | ||
| 283 | if !full { | ||
| 284 | return v | ||
| 285 | } | ||
| 286 | if commit != "" { | ||
| 287 | v.Latest, _ = g.Log(commit, 1) | ||
| 288 | v.Count, _ = g.CountCommits(commit) | ||
| 289 | v.Tree, v.TreeTruncated = buildFileTree(g, commit, active) | ||
| 237 | 290 | } | |
| 238 | 291 | branches, _ := g.ListRefs("refs/heads/") | |
| 239 | 292 | names := make([]string, 0, len(branches)) | |
| @@ -241,51 +294,29 @@ func (w *Web) Code(rw http.ResponseWriter, r *http.Request) { | |||
| 241 | 294 | names = append(names, strings.TrimPrefix(k, "refs/heads/")) | |
| 242 | 295 | } | |
| 243 | 296 | sort.Strings(names) | |
| 244 | var contributorBranches []string | ||
| 245 | for _, name := range names { | ||
| 246 | if _, ok := access.ContributorRefName("refs/heads/" + name); ok { | ||
| 247 | contributorBranches = append(contributorBranches, name) | ||
| 297 | v.Branches = names | ||
| 298 | return v | ||
| 299 | } | ||
| 300 | |||
| 301 | // contributorBranchState lists the branches whose CI is waiting for a maintainer | ||
| 302 | // and reports whether this viewer may approve them. The Runs page owns the | ||
| 303 | // control; the code browser is only for reading files. | ||
| 304 | func (w *Web) contributorBranchState(r *http.Request, m *repo.Meta) ([]string, bool) { | ||
| 305 | refs, _ := m.Git().ListRefs("refs/heads/") | ||
| 306 | var names []string | ||
| 307 | for ref := range refs { | ||
| 308 | if _, ok := access.ContributorRefName(ref); ok { | ||
| 309 | names = append(names, strings.TrimPrefix(ref, "refs/heads/")) | ||
| 248 | 310 | } | |
| 249 | 311 | } | |
| 250 | canAllowCI := false | ||
| 312 | sort.Strings(names) | ||
| 313 | canAllow := false | ||
| 251 | 314 | if acl, err := m.ACL(); err == nil { | |
| 252 | 315 | if id := w.accountID(r); id != "" && acl.CanApprove(id) { | |
| 253 | canAllowCI = true | ||
| 316 | canAllow = true | ||
| 254 | 317 | } | |
| 255 | 318 | } | |
| 256 | w.render(rw, "code", struct { | ||
| 257 | Page | ||
| 258 | Name string | ||
| 259 | Ref string | ||
| 260 | CloneURL string | ||
| 261 | SSHURL string | ||
| 262 | Entries []git.Entry | ||
| 263 | Latest []git.Commit | ||
| 264 | Commit string | ||
| 265 | Count int | ||
| 266 | Branches []string | ||
| 267 | Site string | ||
| 268 | Readme template.HTML | ||
| 269 | ReadmeName string | ||
| 270 | ContributorBranches []string | ||
| 271 | CanAllowCI bool | ||
| 272 | }{ | ||
| 273 | Page: w.page(r, m.Name+" · code"), | ||
| 274 | Name: m.Name, | ||
| 275 | Ref: ref, | ||
| 276 | CloneURL: cloneURL(r, m.Name), | ||
| 277 | SSHURL: sshURL(w.Cfg.SSHAddr, m.Name), | ||
| 278 | Entries: entries, | ||
| 279 | Latest: latest, | ||
| 280 | Commit: commit, | ||
| 281 | Count: count, | ||
| 282 | Branches: names, | ||
| 283 | Site: site, | ||
| 284 | Readme: readme, | ||
| 285 | ReadmeName: readmeName, | ||
| 286 | ContributorBranches: contributorBranches, | ||
| 287 | CanAllowCI: canAllowCI, | ||
| 288 | }, 0) | ||
| 319 | return names, canAllow | ||
| 289 | 320 | } | |
| 290 | 321 | ||
| 291 | 322 | func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |
| @@ -313,14 +344,20 @@ func (w *Web) Tree(rw http.ResponseWriter, r *http.Request) { | |||
| 313 | 344 | http.Error(rw, "not a directory", http.StatusBadRequest) | |
| 314 | 345 | return | |
| 315 | 346 | } | |
| 316 | w.render(rw, "tree", struct { | ||
| 317 | Page | ||
| 318 | Name string | ||
| 319 | Ref string | ||
| 320 | Path string | ||
| 321 | Entries []git.Entry | ||
| 322 | Crumbs []crumb | ||
| 323 | }{w.page(r, m.Name+" · "+ref), m.Name, ref, p, entries, crumbs(p)}, 0) | ||
| 347 | sortEntries(entries) | ||
| 348 | full := !htmxRequest(r) | ||
| 349 | view := w.codeShell(r, m, ref, commit, p, full) | ||
| 350 | view.Mode = "tree" | ||
| 351 | view.Path = p | ||
| 352 | view.Parent = parentPath(p) | ||
| 353 | view.Entries = entries | ||
| 354 | view.Crumbs = crumbs(p) | ||
| 355 | if !full { | ||
| 356 | w.render(rw, "code_main", view, 0) | ||
| 357 | return | ||
| 358 | } | ||
| 359 | view.Page = w.page(r, m.Name+" · "+ref) | ||
| 360 | w.render(rw, "code", view, 0) | ||
| 324 | 361 | } | |
| 325 | 362 | ||
| 326 | 363 | func (w *Web) Blob(rw http.ResponseWriter, r *http.Request) { w.blob(rw, r, false) } | |
| @@ -375,30 +412,39 @@ func (w *Web) blob(rw http.ResponseWriter, r *http.Request, raw bool) { | |||
| 375 | 412 | // Highlight the same bytes the table would show. A language we cannot name | |
| 376 | 413 | // stays plain, which is better than guessing wrong about its grammar. | |
| 377 | 414 | var highlighted template.HTML | |
| 378 | if !binary && !isMarkdown(p) { | ||
| 415 | if !binary { | ||
| 379 | 416 | highlighted = highlightCode(p, strings.Join(lines, "\n")) | |
| 380 | 417 | } | |
| 381 | // Markdown is the one thing a reader expects to see rendered; everything else | ||
| 382 | // is shown as bytes in a <pre>, which is what a source file is. | ||
| 418 | markdown := !binary && isMarkdown(p) | ||
| 419 | // A markdown file opens as source like any other file. Rendering it is an | ||
| 420 | // explicit choice, so the code browser never hides the bytes by default. | ||
| 421 | preview := markdown && !truncated && r.URL.Query().Get("preview") == "1" | ||
| 383 | 422 | var rendered template.HTML | |
| 384 | if !binary && !truncated && isMarkdown(p) { | ||
| 423 | if preview { | ||
| 385 | 424 | rendered = w.renderMarkdown(m.Name, content) | |
| 386 | 425 | } | |
| 387 | w.render(rw, "blob", struct { | ||
| 388 | Page | ||
| 389 | Name string | ||
| 390 | Ref string | ||
| 391 | Path string | ||
| 392 | Content string | ||
| 393 | Markdown template.HTML | ||
| 394 | Binary bool | ||
| 395 | Truncated bool | ||
| 396 | LineCut bool | ||
| 397 | Lines []string | ||
| 398 | Highlight template.HTML | ||
| 399 | Size int64 | ||
| 400 | Crumbs []crumb | ||
| 401 | }{w.page(r, m.Name+" · "+p), m.Name, ref, p, content, rendered, binary, truncated, lineCut, lines, highlighted, int64(len(data)), crumbs(p)}, 0) | ||
| 426 | full := !htmxRequest(r) | ||
| 427 | view := w.codeShell(r, m, ref, commit, p, full) | ||
| 428 | view.Mode = "blob" | ||
| 429 | view.Path = p | ||
| 430 | view.Parent = parentPath(p) | ||
| 431 | view.Crumbs = crumbs(p) | ||
| 432 | view.Content = content | ||
| 433 | view.Markdown = rendered | ||
| 434 | view.Binary = binary | ||
| 435 | view.Truncated = truncated | ||
| 436 | view.LineCut = lineCut | ||
| 437 | view.Lines = lines | ||
| 438 | view.Highlight = highlighted | ||
| 439 | view.Size = int64(len(data)) | ||
| 440 | view.IsMarkdown = markdown | ||
| 441 | view.Preview = preview | ||
| 442 | if !full { | ||
| 443 | w.render(rw, "code_main", view, 0) | ||
| 444 | return | ||
| 445 | } | ||
| 446 | view.Page = w.page(r, m.Name+" · "+p) | ||
| 447 | w.render(rw, "code", view, 0) | ||
| 402 | 448 | } | |
| 403 | 449 | ||
| 404 | 450 | func (w *Web) Commits(rw http.ResponseWriter, r *http.Request) { | |