Add a file browser and polish markdown rendering in gitfed-web
- gitexec.ListTree: list a directory's immediate children at HEAD, reusing the same not-found detection as ReadFileAtHEAD. - admin.Ops: ListRepoTree / GetRepoFile, wired through adminrpc. - gitfed-web: /repo-tree (breadcrumbed directory listing, folders first) and /repo-blob (renders markdown, shows anything else as plain text, detects binary content) — same public/private access check as the repo overview page. "Browse files" link added from there. - render.go: fleshed out .markdown-body CSS (headings, lists, blockquotes, tables, task-list checkboxes, code blocks) instead of the bare styling README/LICENSE were getting before.
10 files changed
+386 −14
A
cmd/gitfed-web/handlers_browse.go
+181 −0
M
cmd/gitfed-web/handlers_repo.go
+1 −0
M
cmd/gitfed-web/render.go
+27 −6
M
cmd/gitfed-web/routes.go
+2 −0
M
internal/admin/admin.go
+20 −0
M
internal/adminrpc/client.go
+13 −0
M
internal/adminrpc/protocol.go
+21 −1
M
internal/adminrpc/server.go
+16 −0
M
internal/gitexec/gitexec.go
+63 −6
M
internal/gitexec/gitexec_test.go
+42 −1
cmd/gitfed-web/handlers_browse.go
@@ -0,0 +1,181 @@
+package main
+
+import (
+ "bytes"
+ "html/template"
+ "net/http"
+ "strings"
+
+ "gitfed/internal/gitexec"
+)
+
+// breadcrumb builds clickable path segments: "cmd/gitfed-web/main.go" becomes
+// repo → cmd → gitfed-web → main.go, each linking back into the tree at that
+// depth (the last segment doesn't link anywhere, it's the current page).
+type crumb struct {
+ Name, Path string
+ Last bool
+}
+
+func breadcrumbs(path string) []crumb {
+ if path == "" {
+ return nil
+ }
+ parts := strings.Split(path, "/")
+ crumbs := make([]crumb, len(parts))
+ for i, part := range parts {
+ crumbs[i] = crumb{
+ Name: part,
+ Path: strings.Join(parts[:i+1], "/"),
+ Last: i == len(parts)-1,
+ }
+ }
+ return crumbs
+}
+
+func parentPath(path string) string {
+ i := strings.LastIndex(path, "/")
+ if i < 0 {
+ return ""
+ }
+ return path[:i]
+}
+
+var treeTpl = template.Must(template.New("tree").Parse(`
+{{.Flash}}
+<p><a href="/r/{{.Repo}}">← {{.Repo}}</a></p>
+<h1>
+ <a href="/repo-tree/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/repo-tree/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
+</h1>
+<table>
+<tr><th>Name</th><th></th></tr>
+{{if .ShowUp}}
+<tr><td><a href="/repo-tree/{{.Repo}}?path={{.ParentPath}}">..</a></td><td class="muted">directory</td></tr>
+{{end}}
+{{range .Entries}}
+<tr>
+ {{if eq .Type "tree"}}
+ <td><a href="/repo-tree/{{$.Repo}}?path={{.FullPath}}">{{.Name}}/</a></td>
+ <td class="muted">directory</td>
+ {{else}}
+ <td><a href="/repo-blob/{{$.Repo}}?path={{.FullPath}}">{{.Name}}</a></td>
+ <td class="muted">file</td>
+ {{end}}
+</tr>
+{{else}}
+{{if not .ShowUp}}<tr><td colspan="2" class="muted">Empty.</td></tr>{{end}}
+{{end}}
+</table>
+`))
+
+type treeEntryView struct {
+ gitexec.TreeEntry
+ FullPath string
+}
+
+func (s *server) handleRepoTree(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+ path := strings.Trim(r.URL.Query().Get("path"), "/")
+
+ repo, err := s.ops.GetRepo(name)
+ if err != nil || !s.canView(r, repo) {
+ http.NotFound(w, r)
+ return
+ }
+
+ entries, found, err := s.ops.ListRepoTree(name, path)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+
+ views := make([]treeEntryView, len(entries))
+ for i, e := range entries {
+ full := e.Name
+ if path != "" {
+ full = path + "/" + e.Name
+ }
+ views[i] = treeEntryView{TreeEntry: e, FullPath: full}
+ }
+
+ var buf bytes.Buffer
+ _ = treeTpl.Execute(&buf, struct {
+ Repo string
+ Crumbs []crumb
+ Entries []treeEntryView
+ ShowUp bool
+ ParentPath string
+ Flash template.HTML
+ }{name, breadcrumbs(path), views, path != "", parentPath(path), flash(r)})
+
+ title := name
+ if path != "" {
+ title = path + " — " + name
+ }
+ s.render(w, r, title, "home", template.HTML(buf.String()))
+}
+
+var blobTpl = template.Must(template.New("blob").Parse(`
+{{.Flash}}
+<p><a href="/r/{{.Repo}}">← {{.Repo}}</a></p>
+<h1>
+ <a href="/repo-tree/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/repo-tree/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}}
+</h1>
+<div class="markdown-body">
+{{.Content}}
+</div>
+`))
+
+func (s *server) handleRepoBlob(w http.ResponseWriter, r *http.Request) {
+ name := r.PathValue("repo")
+ path := strings.Trim(r.URL.Query().Get("path"), "/")
+ if path == "" {
+ http.NotFound(w, r)
+ return
+ }
+
+ repo, err := s.ops.GetRepo(name)
+ if err != nil || !s.canView(r, repo) {
+ http.NotFound(w, r)
+ return
+ }
+
+ content, found, err := s.ops.GetRepoFile(name, path)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if !found {
+ http.NotFound(w, r)
+ return
+ }
+
+ var rendered template.HTML
+ if strings.Contains(content, "\x00") {
+ rendered = "<p class=\"muted\">Binary file — not shown.</p>"
+ } else {
+ fileName := path
+ if i := strings.LastIndex(fileName, "/"); i >= 0 {
+ fileName = fileName[i+1:]
+ }
+ rendered, err = renderFileContent(fileName, content)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ }
+
+ var buf bytes.Buffer
+ _ = blobTpl.Execute(&buf, struct {
+ Repo string
+ Crumbs []crumb
+ Content template.HTML
+ Flash template.HTML
+ }{name, breadcrumbs(path), rendered, flash(r)})
+
+ s.render(w, r, path+" — "+name, "home", template.HTML(buf.String()))
+}
cmd/gitfed-web/handlers_repo.go
@@ -44,6 +44,7 @@ var repoTpl = template.Must(template.New("repo").Parse(`
<h1>{{.Repo.Name}} {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</h1>
<p class="muted">owner: {{.Repo.Owner}}
{{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}}
+· <a href="/repo-tree/{{.Repo.Name}}">browse files</a>
{{if .CanAdminister}} · <a href="/repo-settings/{{.Repo.Name}}">settings</a>{{end}}</p>
<section>
cmd/gitfed-web/render.go
@@ -57,13 +57,34 @@ const shellSrc = `<!doctype html>
.badge.trusted { background: #1e3a2a; color: #9ae6b4; }
.badge.plain { background: #1e3a2a; color: #9ae6b4; margin-right: 0.3rem; }
.muted { color: #9aa1ac; font-size: 0.85rem; }
- code, pre { background: #171a21; border-radius: 4px; }
- code { padding: 0.1rem 0.3rem; }
- pre { padding: 1rem; overflow-x: auto; border: 1px solid #2a2f3a; }
- .markdown-body h1, .markdown-body h2, .markdown-body h3 { border-bottom: 1px solid #2a2f3a; padding-bottom: 0.3rem; }
- .markdown-body img { max-width: 100%; }
- .markdown-body table { display: block; overflow-x: auto; }
+ code, pre { background: #171a21; border-radius: 4px; font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 0.86em; }
+ code { padding: 0.15rem 0.4rem; }
+ pre { padding: 1rem; overflow-x: auto; border: 1px solid #2a2f3a; line-height: 1.5; }
+ pre code { background: none; padding: 0; font-size: 1em; }
section { margin-bottom: 2rem; }
+
+ .markdown-body { line-height: 1.65; font-size: 0.96rem; }
+ .markdown-body > *:first-child { margin-top: 0; }
+ .markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 {
+ border-bottom: 1px solid #2a2f3a; padding-bottom: 0.4rem; margin: 1.8rem 0 1rem;
+ }
+ .markdown-body h1 { font-size: 1.6rem; }
+ .markdown-body h2 { font-size: 1.3rem; }
+ .markdown-body h3 { font-size: 1.1rem; border-bottom: none; }
+ .markdown-body p, .markdown-body ul, .markdown-body ol, .markdown-body blockquote, .markdown-body pre { margin: 0.9rem 0; }
+ .markdown-body ul, .markdown-body ol { padding-left: 1.5rem; }
+ .markdown-body li + li { margin-top: 0.25rem; }
+ .markdown-body a { color: #8ab4f8; }
+ .markdown-body img { max-width: 100%; }
+ .markdown-body hr { border: none; border-top: 1px solid #2a2f3a; margin: 1.8rem 0; }
+ .markdown-body blockquote {
+ margin-left: 0; padding: 0.2rem 1rem; border-left: 3px solid #333944; color: #9aa1ac;
+ }
+ .markdown-body blockquote p { margin: 0.5rem 0; }
+ .markdown-body table { display: block; overflow-x: auto; border-collapse: collapse; width: auto; margin: 1rem 0; }
+ .markdown-body th, .markdown-body td { border: 1px solid #2a2f3a; padding: 0.4rem 0.8rem; text-align: left; }
+ .markdown-body th { background: #171a21; }
+ .markdown-body input[type="checkbox"] { width: auto; margin: 0 0.4em 0 0; }
</style>
</head>
<body>
cmd/gitfed-web/routes.go
@@ -6,6 +6,8 @@ func (s *server) routes(mux *http.ServeMux) {
// Public — no login required.
mux.HandleFunc("GET /{$}", s.handleHome)
mux.HandleFunc("GET /r/{repo...}", s.handleRepoView)
+ mux.HandleFunc("GET /repo-tree/{repo...}", s.handleRepoTree)
+ mux.HandleFunc("GET /repo-blob/{repo...}", s.handleRepoBlob)
mux.HandleFunc("GET /login", s.handleLoginForm)
mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout)
internal/admin/admin.go
@@ -48,6 +48,8 @@ type Ops interface {
GetRepoReadme(name string) (content string, found bool, err error)
GetRepoLicense(name string) (content, filename string, found bool, err error)
ListRepoTags(name string) ([]string, error)
+ ListRepoTree(name, path string) (entries []gitexec.TreeEntry, found bool, err error)
+ GetRepoFile(name, path string) (content string, found bool, err error)
GetACL(repoName string) (store.ACL, error)
GrantCollaborator(repoName, principal string, role store.Role) error
@@ -254,6 +256,24 @@ func (a *Admin) ListRepoTags(name string) ([]string, error) {
return gitexec.ListTags(repo.Path)
}
+func (a *Admin) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return nil, false, err
+ }
+ return gitexec.ListTree(repo.Path, path)
+}
+
+// GetRepoFile returns the content of an exact path at HEAD — unlike
+// GetRepoReadme/GetRepoLicense, it doesn't try alternate filenames.
+func (a *Admin) GetRepoFile(name, path string) (string, bool, error) {
+ repo, err := a.Store.GetRepo(name)
+ if err != nil {
+ return "", false, err
+ }
+ return gitexec.ReadFileAtHEAD(repo.Path, path)
+}
+
// GrantCollaborator adds/updates a collaborator's role on a repo. If the
// principal belongs to a remote domain, it first resolves trust for that
// domain (§5.2/§6); for the whitelist policy this leaves the domain pending
internal/adminrpc/client.go
@@ -7,6 +7,7 @@ import (
"time"
"gitfed/internal/admin"
+ "gitfed/internal/gitexec"
"gitfed/internal/store"
)
@@ -163,6 +164,18 @@ func (c *Client) ListRepoTags(name string) ([]string, error) {
return out.Tags, err
}
+func (c *Client) ListRepoTree(name, path string) ([]gitexec.TreeEntry, bool, error) {
+ var out listTreeResult
+ err := c.call(methodListRepoTree, pathArgs{Name: name, Path: path}, &out)
+ return out.Entries, out.Found, err
+}
+
+func (c *Client) GetRepoFile(name, path string) (string, bool, error) {
+ var out fileResult
+ err := c.call(methodGetRepoFile, pathArgs{Name: name, Path: path}, &out)
+ return out.Content, out.Found, err
+}
+
func (c *Client) GetUser(username string) (store.User, error) {
var out userResult
err := c.call(methodGetUser, nameArgs{Name: username}, &out)
internal/adminrpc/protocol.go
@@ -7,7 +7,10 @@
// entirely.
package adminrpc
-import "gitfed/internal/store"
+import (
+ "gitfed/internal/gitexec"
+ "gitfed/internal/store"
+)
// method names
const (
@@ -31,6 +34,8 @@ const (
methodGetRepoReadme = "GetRepoReadme"
methodGetRepoLicense = "GetRepoLicense"
methodListRepoTags = "ListRepoTags"
+ methodListRepoTree = "ListRepoTree"
+ methodGetRepoFile = "GetRepoFile"
methodGetUser = "GetUser"
methodSetUserAdmin = "SetUserAdmin"
methodSetPassword = "SetPassword"
@@ -122,6 +127,21 @@ type listTagsResult struct {
Tags []string `json:"tags"`
}
+type pathArgs struct {
+ Name string `json:"name"`
+ Path string `json:"path"`
+}
+
+type listTreeResult struct {
+ Entries []gitexec.TreeEntry `json:"entries"`
+ Found bool `json:"found"`
+}
+
+type fileResult struct {
+ Content string `json:"content"`
+ Found bool `json:"found"`
+}
+
type userResult struct {
User store.User `json:"user"`
}
internal/adminrpc/server.go
@@ -201,6 +201,22 @@ func (s *Server) dispatch(req wireRequest) (any, error) {
tags, err := s.ops.ListRepoTags(a.Name)
return listTagsResult{Tags: tags}, err
+ case methodListRepoTree:
+ var a pathArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ entries, found, err := s.ops.ListRepoTree(a.Name, a.Path)
+ return listTreeResult{Entries: entries, Found: found}, err
+
+ case methodGetRepoFile:
+ var a pathArgs
+ if err := json.Unmarshal(req.Args, &a); err != nil {
+ return nil, err
+ }
+ content, found, err := s.ops.GetRepoFile(a.Name, a.Path)
+ return fileResult{Content: content, Found: found}, err
+
case methodGetUser:
var a nameArgs
if err := json.Unmarshal(req.Args, &a); err != nil {
internal/gitexec/gitexec.go
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
+ "sort"
"strings"
)
@@ -118,18 +119,74 @@ func ReadFileAtHEAD(repoPath, filename string) (content string, found bool, err
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
- msg := stderr.String()
- if strings.Contains(msg, "does not exist") ||
- strings.Contains(msg, "bad revision") ||
- strings.Contains(msg, "not in the tree") ||
- strings.Contains(msg, "invalid object name") {
+ if notFoundGitError(stderr.String()) {
return "", false, nil
}
- return "", false, fmt.Errorf("gitexec: read %s at HEAD: %w: %s", filename, err, msg)
+ return "", false, fmt.Errorf("gitexec: read %s at HEAD: %w: %s", filename, err, stderr.String())
}
return stdout.String(), true, nil
}
+// TreeEntry is one immediate child of a directory in the repo's tree.
+type TreeEntry struct {
+ Name string `json:"name"`
+ Type string `json:"type"` // "blob" (file) or "tree" (directory)
+}
+
+func notFoundGitError(msg string) bool {
+ return strings.Contains(msg, "does not exist") ||
+ strings.Contains(msg, "bad revision") ||
+ strings.Contains(msg, "not in the tree") ||
+ strings.Contains(msg, "invalid object name") ||
+ strings.Contains(msg, "valid object name") ||
+ strings.Contains(msg, "not a tree object")
+}
+
+// ListTree returns the immediate children (files and directories) at path
+// in the tree at HEAD, directories first then files, alphabetically within
+// each. path == "" lists the repo root. found is false (nil error) for an
+// empty repo or a path that doesn't exist — both unremarkable.
+func ListTree(repoPath, path string) (entries []TreeEntry, found bool, err error) {
+ path = strings.Trim(path, "/")
+ treeish := "HEAD"
+ if path != "" {
+ treeish = "HEAD:" + path
+ }
+
+ cmd := exec.Command("git", "--git-dir="+repoPath, "ls-tree", treeish)
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ if err := cmd.Run(); err != nil {
+ if notFoundGitError(stderr.String()) {
+ return nil, false, nil
+ }
+ return nil, false, fmt.Errorf("gitexec: list tree at %q: %w: %s", path, err, stderr.String())
+ }
+
+ for _, line := range strings.Split(stdout.String(), "\n") {
+ if line == "" {
+ continue
+ }
+ tab := strings.IndexByte(line, '\t')
+ if tab < 0 {
+ continue
+ }
+ meta := strings.Fields(line[:tab])
+ if len(meta) < 2 {
+ continue
+ }
+ entries = append(entries, TreeEntry{Name: line[tab+1:], Type: meta[1]})
+ }
+ sort.Slice(entries, func(i, j int) bool {
+ if (entries[i].Type == "tree") != (entries[j].Type == "tree") {
+ return entries[i].Type == "tree"
+ }
+ return entries[i].Name < entries[j].Name
+ })
+ return entries, true, nil
+}
+
// ListTags returns the repo's git tags, most recently created first.
func ListTags(repoPath string) ([]string, error) {
cmd := exec.Command("git", "--git-dir="+repoPath, "tag", "--list", "--sort=-creatordate")
internal/gitexec/gitexec_test.go
@@ -35,7 +35,13 @@ func TestReadFileAtHEADAndListTags(t *testing.T) {
if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# Hello\n"), 0644); err != nil {
t.Fatal(err)
}
- run(t, work, "git", "add", "README.md")
+ if err := os.Mkdir(filepath.Join(work, "cmd"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(work, "cmd", "main.go"), []byte("package main\n"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ run(t, work, "git", "add", "README.md", "cmd/main.go")
run(t, work, "git", "commit", "-q", "-m", "initial")
run(t, work, "git", "tag", "v1.0.0")
run(t, work, "git", "remote", "add", "origin", barePath)
@@ -67,6 +73,33 @@ func TestReadFileAtHEADAndListTags(t *testing.T) {
if len(tags) != 1 || tags[0] != "v1.0.0" {
t.Fatalf("tags = %v, want [v1.0.0]", tags)
}
+
+ root, found, err := ListTree(barePath, "")
+ if err != nil {
+ t.Fatalf("ListTree root: %v", err)
+ }
+ if !found {
+ t.Fatal("root tree should be found")
+ }
+ if len(root) != 2 || root[0].Name != "cmd" || root[0].Type != "tree" || root[1].Name != "README.md" || root[1].Type != "blob" {
+ t.Fatalf("root tree = %+v, want [cmd(tree) README.md(blob)]", root)
+ }
+
+ sub, found, err := ListTree(barePath, "cmd")
+ if err != nil {
+ t.Fatalf("ListTree cmd: %v", err)
+ }
+ if !found || len(sub) != 1 || sub[0].Name != "main.go" || sub[0].Type != "blob" {
+ t.Fatalf("cmd tree = %+v found=%v, want [main.go(blob)]", sub, found)
+ }
+
+ _, found, err = ListTree(barePath, "nope")
+ if err != nil {
+ t.Fatalf("ListTree nonexistent path: %v", err)
+ }
+ if found {
+ t.Fatal("nonexistent path should not be found")
+ }
}
func TestReadFileAtHEADEmptyRepo(t *testing.T) {
@@ -91,4 +124,12 @@ func TestReadFileAtHEADEmptyRepo(t *testing.T) {
if len(tags) != 0 {
t.Fatalf("tags = %v, want none", tags)
}
+
+ _, found, err = ListTree(barePath, "")
+ if err != nil {
+ t.Fatalf("ListTree on empty repo: %v", err)
+ }
+ if found {
+ t.Fatal("empty repo should never report a tree as found")
+ }
}