Gitfed
bastien-mrq/gitfed/ Commits/ d61d88b

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.

bastien-mrq 2026-07-28 13:41 commit d61d88b144e79a0b91054584c15a81fe6ab2bd8f parent 0a6f3b879e54b240fc711f9245ec104fd6cbeafc
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
diff --git a/cmd/gitfed-web/handlers_browse.go b/cmd/gitfed-web/handlers_browse.go new file mode 100644 index 0000000..9096fb8 --- /dev/null +++ b/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}}">&larr; {{.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}}">&larr; {{.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
diff --git a/cmd/gitfed-web/handlers_repo.go b/cmd/gitfed-web/handlers_repo.go index c3dc1e4..f2cde68 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/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
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 7bc8ec8..8b7c993 100644 --- a/cmd/gitfed-web/render.go +++ b/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
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index 8d83c1e..46da898 100644 --- a/cmd/gitfed-web/routes.go +++ b/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
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 5395974..a81706c 100644 --- a/internal/admin/admin.go +++ b/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
diff --git a/internal/adminrpc/client.go b/internal/adminrpc/client.go index f4c3272..b89481a 100644 --- a/internal/adminrpc/client.go +++ b/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
diff --git a/internal/adminrpc/protocol.go b/internal/adminrpc/protocol.go index 9bf3e1a..678818e 100644 --- a/internal/adminrpc/protocol.go +++ b/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
diff --git a/internal/adminrpc/server.go b/internal/adminrpc/server.go index 8badfac..319103a 100644 --- a/internal/adminrpc/server.go +++ b/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
diff --git a/internal/gitexec/gitexec.go b/internal/gitexec/gitexec.go index 050a789..b646d06 100644 --- a/internal/gitexec/gitexec.go +++ b/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
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index 7b57937..0c4eb43 100644 --- a/internal/gitexec/gitexec_test.go +++ b/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") + } }