Commit 746c5176bc

746c5176bc03909f4ec7e0af4fbf93b77b477dc9

parent: 34657cd973

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 16:20 UTC

web: a file navigator beside blob, blame and edit

Ref #226
e2e/filenav_test.go added +52
@@ -0,0 +1,52 @@
1package e2e
2
3import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8)
9
10// A file page lists its directory beside the file, marks the file, and
11// links up (desktop layout spec).
12func TestFileNavigator(t *testing.T) {
13 inst := startInstance(t)
14 key := inst.newKey(t, "alice")
15 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
16 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/nav"); code != 0 {
17 t.Fatalf("repo create: %s", errOut)
18 }
19 // the same clone-commit-push shape TestWebUI uses (e2e/web_test.go:41-50)
20 work := t.TempDir()
21 env := inst.gitEnv(key)
22 mustGit(t, work, env, "clone", inst.sshURL("alice/nav"), "w")
23 dir := filepath.Join(work, "w")
24 os.MkdirAll(filepath.Join(dir, "cmd", "sub"), 0o755)
25 os.WriteFile(filepath.Join(dir, "README.md"), []byte("# nav\n"), 0o644)
26 os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0o644)
27 os.WriteFile(filepath.Join(dir, "cmd", "sub", "x.go"), []byte("package sub\n"), 0o644)
28 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
29 mustGit(t, dir, env, "add", ".")
30 mustGit(t, dir, env, "commit", "-q", "-m", "one")
31 mustGit(t, dir, env, "push", "-q", "origin", "main")
32
33 status, body := inst.get(t, "/alice/nav/blob/main/cmd/main.go")
34 if status != 200 {
35 t.Fatalf("blob: %d", status)
36 }
37 for _, want := range []string{
38 `<nav class="filenav" aria-label="Files">`,
39 `<h2 class="colhead">cmd</h2>`,
40 `<a class="up" href="/alice/nav/tree/main">..</a>`,
41 `<a class="dir" href="/alice/nav/tree/main/cmd/sub">sub/</a>`,
42 `<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`,
43 } {
44 if !strings.Contains(body, want) {
45 t.Errorf("blob page lacks %q", want)
46 }
47 }
48 _, body = inst.get(t, "/alice/nav/blame/main/README.md")
49 if !strings.Contains(body, `<h2 class="colhead">nav</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/README.md">README.md</a>`) {
50 t.Errorf("blame page lacks the root navigator:\n%s", body)
51 }
52}
internal/httpd/accounts.go +4
@@ -525,6 +525,7 @@ type editPage struct {
525525 // Preview button makes sense; Draft holds one when asked for (#235).
526526 Markup bool
527527 Draft *draft
528 Nav fileNav
528529}
529530
530531func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -559,10 +560,13 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
559560 http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest)
560561 return
561562 }
563 navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
564 nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
562565 s.render(w, "edit.html", editPage{
563566 basePage: s.baseFor(u), Repo: repo,
564567 Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating,
565568 Markup: markupFile(filePath),
569 Nav: nav,
566570 })
567571}
568572
internal/httpd/filenav.go added +72
@@ -0,0 +1,72 @@
1package httpd
2
3import (
4 "path"
5 "sort"
6
7 "gitbay.org/gitbay/internal/gitutil"
8)
9
10// fileNav is the column beside a file: its directory's entries, the file
11// marked, and a link up. It is the tree page's listing rendered as a
12// list, so reading a repository does not mean going back for each file.
13type fileNav struct {
14 Title string // the directory, or the repository name at the root
15 Parent string // URL of the parent tree; "" at the root
16 Entries []fileNavEntry
17}
18
19type fileNavEntry struct {
20 Name string // directories carry a trailing slash
21 URL string
22 Dir bool
23 Current bool
24}
25
26// sortDirsFirst orders a listing by shape before name, stably, so each
27// group keeps the order git gave it. The tree page and the navigator
28// share it.
29func sortDirsFirst(entries []gitutil.TreeEntry) {
30 sort.SliceStable(entries, func(i, j int) bool {
31 return entries[i].Type == "tree" && entries[j].Type != "tree"
32 })
33}
34
35// navDir is the directory ListTree wants for filePath: "" at the root.
36func navDir(filePath string) string {
37 if d := path.Dir(filePath); d != "." {
38 return d
39 }
40 return ""
41}
42
43// fileNavFor builds the navigator for filePath from its directory's
44// entries. repoPath is owner/name.
45func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav {
46 dir := path.Dir(filePath)
47 if dir == "." {
48 dir = ""
49 }
50 base := "/" + repoPath
51 nav := fileNav{Title: dir}
52 if dir == "" {
53 nav.Title = path.Base(repoPath)
54 } else if up := path.Dir(dir); up == "." {
55 nav.Parent = base + "/tree/" + ref
56 } else {
57 nav.Parent = base + "/tree/" + ref + "/" + up
58 }
59 sortDirsFirst(entries)
60 for _, e := range entries {
61 full := path.Join(dir, e.Name)
62 ent := fileNavEntry{Name: e.Name, Dir: e.Type == "tree", Current: full == filePath}
63 if ent.Dir {
64 ent.Name += "/"
65 ent.URL = base + "/tree/" + ref + "/" + full
66 } else {
67 ent.URL = base + "/blob/" + ref + "/" + full
68 }
69 nav.Entries = append(nav.Entries, ent)
70 }
71 return nav
72}
internal/httpd/filenav_test.go added +42
@@ -0,0 +1,42 @@
1package httpd
2
3import (
4 "testing"
5
6 "gitbay.org/gitbay/internal/gitutil"
7)
8
9// The navigator lists the file's directory, directories first, links each
10// entry to its tree or blob page, marks the file itself, and links the
11// parent (the tree root when the file is at the top).
12func TestFileNavMarksCurrentAndLinksParent(t *testing.T) {
13 entries := []gitutil.TreeEntry{
14 {Type: "blob", Name: "main.go"},
15 {Type: "tree", Name: "sub"},
16 {Type: "blob", Name: "util.go"},
17 }
18 nav := fileNavFor("krz/gitbay", "main", "cmd/gitbay/util.go", entries)
19 if nav.Title != "cmd/gitbay" {
20 t.Errorf("title = %q", nav.Title)
21 }
22 if nav.Parent != "/krz/gitbay/tree/main/cmd" {
23 t.Errorf("parent = %q", nav.Parent)
24 }
25 if len(nav.Entries) != 3 || nav.Entries[0].Name != "sub/" || !nav.Entries[0].Dir {
26 t.Fatalf("entries not directories-first: %+v", nav.Entries)
27 }
28 if nav.Entries[0].URL != "/krz/gitbay/tree/main/cmd/gitbay/sub" {
29 t.Errorf("dir url = %q", nav.Entries[0].URL)
30 }
31 if nav.Entries[2].Name != "util.go" || !nav.Entries[2].Current || nav.Entries[2].URL != "/krz/gitbay/blob/main/cmd/gitbay/util.go" {
32 t.Errorf("current entry: %+v", nav.Entries[2])
33 }
34 if nav.Entries[1].Current {
35 t.Error("main.go marked current")
36 }
37
38 root := fileNavFor("krz/gitbay", "main", "Makefile", []gitutil.TreeEntry{{Type: "blob", Name: "Makefile"}})
39 if root.Title != "gitbay" || root.Parent != "" {
40 t.Errorf("root nav: title %q parent %q", root.Title, root.Parent)
41 }
42}
internal/httpd/web.go +9 −9
@@ -20,7 +20,6 @@ import (
2020 "net/url"
2121 "path"
2222 "regexp"
23 "sort"
2423 "strconv"
2524 "strings"
2625 "time"
@@ -521,12 +520,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
521520 s.notFound(w, r)
522521 return
523522 }
524 // Directories first. git's tree order interleaves them with files, but
525 // a listing is scanned by shape before name. Stable, so each group
526 // keeps the ordering git gave it.
527 sort.SliceStable(entries, func(i, j int) bool {
528 return entries[i].Type == "tree" && entries[j].Type != "tree"
529 })
523 sortDirsFirst(entries)
530524 prefix := ""
531525 if dirPath != "" {
532526 prefix = dirPath + "/"
@@ -591,6 +585,8 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
591585 cs = cs[:len(cs)-1]
592586 }
593587 branches, _ := gitutil.Refs(p.Dir, "heads")
588 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
589 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
594590 lines := 0
595591 if !binary && !image && len(data) > 0 {
596592 lines = bytes.Count(data, []byte("\n"))
@@ -619,8 +615,9 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
619615 Renderable bool // markdown or org: the toggle is offered
620616 Rendered bool // this response shows the rendering
621617 RenderedHTML template.HTML
618 Nav fileNav
622619 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
623 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
620 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav})
624621}
625622
626623// releases lists tag-anchored releases with notes and assets.
@@ -924,6 +921,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
924921 base = cs[len(cs)-1].Name
925922 cs = cs[:len(cs)-1]
926923 }
924 navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
925 nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
927926 s.render(w, "blame.html", struct {
928927 repoPage
929928 Crumbs []crumb
@@ -932,7 +931,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
932931 Binary bool
933932 Hunks []hunkView
934933 Page, Pages int
935 }{p, cs, base, filePath, binary, hunks, page, pages})
934 Nav fileNav
935 }{p, cs, base, filePath, binary, hunks, page, pages, nav})
936936}
937937
938938type numberedLine struct {
internal/web/static/style.css +24
@@ -1370,6 +1370,26 @@ a.memberchip .role { color: var(--muted); }
13701370.crumbs { color: var(--muted); }
13711371.crumbs a { color: var(--link); }
13721372.crumbs strong { color: var(--fg); }
1373
1374/* ---- file navigator: the directory beside a file ---- */
1375.blobgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
1376.blobmain { min-width: 0; }
1377.filenav { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
1378.filenav ul { list-style: none; margin: 0; padding: 0; }
1379.filenav li a {
1380 display: block;
1381 padding: 3px var(--sp-2);
1382 border-radius: var(--r-ctl);
1383 color: var(--fg);
1384 font-family: var(--mono);
1385 font-size: var(--fs-1);
1386 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
1387}
1388.filenav li a:hover { background: var(--hover); text-decoration: none; }
1389.filenav li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 600; }
1390.filenav li a.dir { color: var(--link); }
1391.filenav li a.up { color: var(--muted); }
1392
13731393.refchip {
13741394 display: inline-flex;
13751395 align-items: center;
@@ -1578,6 +1598,10 @@ svg.icon { vertical-align: -0.125em; }
15781598 .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
15791599 .pins .n, .dashpins .meta { display: none; }
15801600 .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
1601
1602 /* the tree page is the navigator on a phone */
1603 .blobgrid { grid-template-columns: 1fr; }
1604 .filenav { display: none; }
15811605}
15821606
15831607@media (max-width: 52rem) {
internal/web/templates/blame.html +5
@@ -2,6 +2,9 @@
22{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
44<h1 class="vh">Blame: {{.Path}}</h1>
5<div class="blobgrid">
6{{template "filenav" .Nav}}
7<div class="blobmain">
58<div class="pathbar">
69 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
710 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
@@ -23,4 +26,6 @@
2326</div>
2427{{if gt .Pages 1}}<p class="pager">page {{.Page}} of {{.Pages}}{{if gt .Page 1}} · <a href="?page={{sub .Page 1}}">← earlier lines</a>{{end}}{{if lt .Page .Pages}} · <a href="?page={{add .Page 1}}">later lines →</a>{{end}}</p>{{end}}
2528{{end}}
29</div>
30</div>
2631{{end}}
internal/web/templates/blob.html +5
@@ -2,6 +2,9 @@
22{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
44<h1 class="vh">{{.Path}}</h1>
5<div class="blobgrid">
6{{template "filenav" .Nav}}
7<div class="blobmain">
58<div class="pathbar">
69 {{template "refmenu" .}}
710 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
@@ -13,4 +16,6 @@
1316{{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
1417{{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section>
1518{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
19</div>
20</div>
1621{{end}}
internal/web/templates/edit.html +5
@@ -2,6 +2,9 @@
22{{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
33{{define "content"}}
44<h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1>
5<div class="blobgrid">
6{{template "filenav" .Nav}}
7<div class="blobmain">
58{{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}}
69{{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}}
710{{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}}
@@ -13,4 +16,6 @@
1316<p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p>
1417</form>
1518{{end}}
19</div>
20</div>
1621{{end}}
internal/web/templates/layout.html +8
@@ -114,6 +114,14 @@
114114 </div>
115115</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}}
116116
117{{define "filenav"}}<nav class="filenav" aria-label="Files">
118 <h2 class="colhead">{{.Title}}</h2>
119 <ul>
120 {{with .Parent}}<li><a class="up" href="{{.}}">..</a></li>{{end}}
121 {{range .Entries}}<li><a{{if .Dir}} class="dir"{{end}}{{if .Current}} aria-current="page"{{end}} href="{{.URL}}">{{.Name}}</a></li>
122 {{end}}</ul>
123</nav>{{end}}
124
117125{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}}
118126
119127{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}