Commit 746c5176bc
Verified · cmc
e2e/filenav_test.go added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 12 | func 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 { | ||
| 525 | 525 | // Preview button makes sense; Draft holds one when asked for (#235). |
| 526 | 526 | Markup bool |
| 527 | 527 | Draft *draft |
| 528 | Nav fileNav | |
| 528 | 529 | } |
| 529 | 530 | |
| 530 | 531 | func (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) | ||
| 559 | 560 | http.Error(w, "binary files cannot be edited in the browser", http.StatusBadRequest) |
| 560 | 561 | return |
| 561 | 562 | } |
| 563 | navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath)) | |
| 564 | nav := fileNavFor(repo.Path(), ref, filePath, navEntries) | |
| 562 | 565 | s.render(w, "edit.html", editPage{ |
| 563 | 566 | basePage: s.baseFor(u), Repo: repo, |
| 564 | 567 | Ref: ref, Path: filePath, Content: string(content), Blocked: blocked, Creating: creating, |
| 565 | 568 | Markup: markupFile(filePath), |
| 569 | Nav: nav, | |
| 566 | 570 | }) |
| 567 | 571 | } |
| 568 | 572 | |
internal/httpd/filenav.go added +72
| @@ -0,0 +1,72 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 13 | type 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 | ||
| 19 | type 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. | |
| 29 | func 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. | |
| 36 | func 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. | |
| 45 | func 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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). | |
| 12 | func 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 ( | ||
| 20 | 20 | "net/url" |
| 21 | 21 | "path" |
| 22 | 22 | "regexp" |
| 23 | "sort" | |
| 24 | 23 | "strconv" |
| 25 | 24 | "strings" |
| 26 | 25 | "time" |
| @@ -521,12 +520,7 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, | ||
| 521 | 520 | s.notFound(w, r) |
| 522 | 521 | return |
| 523 | 522 | } |
| 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) | |
| 530 | 524 | prefix := "" |
| 531 | 525 | if dirPath != "" { |
| 532 | 526 | prefix = dirPath + "/" |
| @@ -591,6 +585,8 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 591 | 585 | cs = cs[:len(cs)-1] |
| 592 | 586 | } |
| 593 | 587 | 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) | |
| 594 | 590 | lines := 0 |
| 595 | 591 | if !binary && !image && len(data) > 0 { |
| 596 | 592 | lines = bytes.Count(data, []byte("\n")) |
| @@ -619,8 +615,9 @@ func (s *Server) blob(w http.ResponseWriter, r *http.Request) { | ||
| 619 | 615 | Renderable bool // markdown or org: the toggle is offered |
| 620 | 616 | Rendered bool // this response shows the rendering |
| 621 | 617 | RenderedHTML template.HTML |
| 618 | Nav fileNav | |
| 622 | 619 | }{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}) | |
| 624 | 621 | } |
| 625 | 622 | |
| 626 | 623 | // releases lists tag-anchored releases with notes and assets. |
| @@ -924,6 +921,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) { | ||
| 924 | 921 | base = cs[len(cs)-1].Name |
| 925 | 922 | cs = cs[:len(cs)-1] |
| 926 | 923 | } |
| 924 | navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath)) | |
| 925 | nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries) | |
| 927 | 926 | s.render(w, "blame.html", struct { |
| 928 | 927 | repoPage |
| 929 | 928 | Crumbs []crumb |
| @@ -932,7 +931,8 @@ func (s *Server) blame(w http.ResponseWriter, r *http.Request) { | ||
| 932 | 931 | Binary bool |
| 933 | 932 | Hunks []hunkView |
| 934 | 933 | 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}) | |
| 936 | 936 | } |
| 937 | 937 | |
| 938 | 938 | type numberedLine struct { |
internal/web/static/style.css +24
| @@ -1370,6 +1370,26 @@ a.memberchip .role { color: var(--muted); } | ||
| 1370 | 1370 | .crumbs { color: var(--muted); } |
| 1371 | 1371 | .crumbs a { color: var(--link); } |
| 1372 | 1372 | .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 | ||
| 1373 | 1393 | .refchip { |
| 1374 | 1394 | display: inline-flex; |
| 1375 | 1395 | align-items: center; |
| @@ -1578,6 +1598,10 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1578 | 1598 | .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); } |
| 1579 | 1599 | .pins .n, .dashpins .meta { display: none; } |
| 1580 | 1600 | .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; } | |
| 1581 | 1605 | } |
| 1582 | 1606 | |
| 1583 | 1607 | @media (max-width: 52rem) { |
internal/web/templates/blame.html +5
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1 class="vh">Blame: {{.Path}}</h1> |
| 5 | <div class="blobgrid"> | |
| 6 | {{template "filenav" .Nav}} | |
| 7 | <div class="blobmain"> | |
| 5 | 8 | <div class="pathbar"> |
| 6 | 9 | <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> |
| 7 | 10 | <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 @@ | ||
| 23 | 26 | </div> |
| 24 | 27 | {{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}} |
| 25 | 28 | {{end}} |
| 29 | </div> | |
| 30 | </div> | |
| 26 | 31 | {{end}} |
internal/web/templates/blob.html +5
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1 class="vh">{{.Path}}</h1> |
| 5 | <div class="blobgrid"> | |
| 6 | {{template "filenav" .Nav}} | |
| 7 | <div class="blobmain"> | |
| 5 | 8 | <div class="pathbar"> |
| 6 | 9 | {{template "refmenu" .}} |
| 7 | 10 | <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 @@ | ||
| 13 | 16 | {{else if .Binary}}<p class="empty-note">binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p> |
| 14 | 17 | {{else if .Rendered}}<section class="readme"><div class="rendered">{{.RenderedHTML}}</div></section> |
| 15 | 18 | {{else}}<div class="code">{{.CodeHTML}}</div>{{end}} |
| 19 | </div> | |
| 20 | </div> | |
| 16 | 21 | {{end}} |
internal/web/templates/edit.html +5
| @@ -2,6 +2,9 @@ | ||
| 2 | 2 | {{define "title"}}edit {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 4 | <h1>edit {{.Repo.OwnerName}}/{{.Repo.Name}} : {{.Path}} @ {{.Ref}}</h1> |
| 5 | <div class="blobgrid"> | |
| 6 | {{template "filenav" .Nav}} | |
| 7 | <div class="blobmain"> | |
| 5 | 8 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 6 | 9 | {{if .Blocked}}<p class="empty-note">{{.Blocked}}</p>{{else}} |
| 7 | 10 | {{if .Creating}}<p class="meta"><code>{{.Path}}</code> does not exist on {{.Ref}}; committing creates it.</p>{{end}} |
| @@ -13,4 +16,6 @@ | ||
| 13 | 16 | <p class="crumbs">this commit will be unsigned and authored as {{.Viewer}}</p> |
| 14 | 17 | </form> |
| 15 | 18 | {{end}} |
| 19 | </div> | |
| 20 | </div> | |
| 16 | 21 | {{end}} |
internal/web/templates/layout.html +8
| @@ -114,6 +114,14 @@ | ||
| 114 | 114 | </div> |
| 115 | 115 | </details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}} |
| 116 | 116 | |
| 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 | ||
| 117 | 125 | {{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}} |
| 118 | 126 | |
| 119 | 127 | {{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{sigLabel .State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}} |