Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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) {