Gitfed
bastien-mrq/gitfed/ Commits/ f9840af

Redesign the web UI: GitLab-style repo page, responsive navbar, search

- Repo page merged with the file browser: file table directly above the rendered README (root only, matching GitLab's scoping), no separate "browse files" page anymore. Added a branch label, a clone URL with a copy-to-clipboard button, and a real empty-state for repos with no commits yet instead of a blank 404. - Navbar rebuilt: search input (repos by name/topic, Cmd/Ctrl-K to focus), a user avatar, collapsing to a hamburger menu below ~720px instead of the old bar overflowing. - New GET /search. - Fuller design token system (canvas/surface/border/text-dim/accent/etc.) replacing scattered hardcoded hex colors; every table now scrolls horizontally instead of breaking layout on narrow viewports. - gitexec.DefaultBranchName, exposed through admin.Ops/adminrpc, backs the branch label. Proposed as an HTML mockup first and approved before implementing.

bastien-mrq 2026-07-28 14:34 commit f9840af24165d3bda92c4f01cb05726745d75bcb parent 5528a28f81e27e384a65fa0d8f1be5c312035a33
11 files changed +601 −284
D cmd/gitfed-web/handlers_browse.go +0 −181
M cmd/gitfed-web/handlers_repo.go +216 −39
A cmd/gitfed-web/handlers_search.go +78 −0
M cmd/gitfed-web/render.go +216 −63
M cmd/gitfed-web/routes.go +1 −1
M internal/admin/admin.go +10 −0
M internal/adminrpc/client.go +6 −0
M internal/adminrpc/protocol.go +6 −0
M internal/adminrpc/server.go +8 −0
M internal/gitexec/gitexec.go +18 −0
M internal/gitexec/gitexec_test.go +42 −0
cmd/gitfed-web/handlers_browse.go
diff --git a/cmd/gitfed-web/handlers_browse.go b/cmd/gitfed-web/handlers_browse.go deleted file mode 100644 index 9096fb8..0000000 --- a/cmd/gitfed-web/handlers_browse.go +++ /dev/null @@ -1,181 +0,0 @@ -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 f2cde68..11b67b3 100644 --- a/cmd/gitfed-web/handlers_repo.go +++ b/cmd/gitfed-web/handlers_repo.go @@ -7,6 +7,7 @@ import ( "net/url" "strings" + "gitfed/internal/gitexec" "gitfed/internal/store" ) @@ -38,44 +39,110 @@ func (s *server) canAdminister(r *http.Request, repoName string) (string, bool) return sess.Principal, err == nil && allowed } +// crumb is one clickable segment of an in-repo path breadcrumb. +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] +} + +type treeEntryView struct { + gitexec.TreeEntry + FullPath string +} + +// repoTpl renders the repo's file tree at the current path, GitLab-style: +// files/dirs at the top, README (and, root only, LICENSE) rendered directly +// below — no separate "browse files" page. var repoTpl = template.Must(template.New("repo").Parse(` {{.Flash}} -<p><a href="/">&larr; All repos</a></p> -<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> -<code>ssh://git@{{.Domain}}:2222/{{.Repo.Name}}.git</code> -<p class="muted">Cloning requires a gitfed SSH identity — this web login is separate from git access.</p> -</section> +<div class="gf-crumbs"><a href="/">Explore</a><span class="sep">/</span>{{.Repo.Name}}</div> + +<div class="gf-repo-head"> + <h1>{{.Repo.Name}}</h1> + {{if .Repo.Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}} + {{range .Repo.Topics}}<span class="badge plain">{{.}}</span>{{end}} +</div> +<p class="muted">owner: {{.Repo.Owner}}</p> + +<div class="gf-action-bar"> + {{if .Branch}}<span class="gf-btn">⎇ {{.Branch}}</span>{{end}} + <div class="gf-clone-url"> + <code>{{.CloneURL}}</code> + <button type="button" class="linklike" data-copy="{{.CloneURL}}" title="Copy clone URL" aria-label="Copy clone URL">⧉</button> + </div> + {{if .CanAdminister}}<a href="/repo-settings/{{.Repo.Name}}" class="gf-btn">Settings</a>{{end}} +</div> + +{{if .Crumbs}} +<div class="gf-crumbs"> + <a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo.Name}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}} +</div> +{{end}} + +{{if .Empty}} +<div class="gf-card" style="padding: 1.5rem;"> + <p class="muted" style="margin: 0 0 0.6rem;">This repository is empty.</p> + <code>git clone {{.CloneURL}}</code> +</div> +{{else}} +<div class="gf-card"> + <table class="gf-file-table"> + {{if .ShowUp}}<tr><td class="icon">📁</td><td class="name"><a href="/r/{{.Repo.Name}}?path={{.ParentPath}}">..</a></td><td class="meta"></td></tr>{{end}} + {{range .Entries}} + {{if eq .Type "tree"}} + <tr><td class="icon">📁</td><td class="name"><a href="/r/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">directory</td></tr> + {{else}} + <tr><td class="icon">📄</td><td class="name"><a href="/repo-blob/{{$.Repo.Name}}?path={{.FullPath}}">{{.Name}}</a></td><td class="meta">file</td></tr> + {{end}} + {{else}} + {{if not .ShowUp}}<tr><td colspan="3" class="muted" style="padding:0.9rem;">Nothing here.</td></tr>{{end}} + {{end}} + </table> +</div> +{{end}} {{if .Tags}} -<section> -<h3>Tags</h3> -{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}} -</section> +<section><h3>Tags</h3>{{range .Tags}}<span class="badge plain">{{.}}</span> {{end}}</section> {{end}} {{if .ReadmeHTML}} -<section class="markdown-body"> -<h3>README</h3> -{{.ReadmeHTML}} -</section> +<div class="gf-card"> + <div class="gf-readme-head">📄 README</div> + <div class="gf-readme-body markdown-body">{{.ReadmeHTML}}</div> +</div> {{end}} {{if .LicenseHTML}} -<section class="markdown-body"> -<h3>License ({{.LicenseFile}})</h3> -{{.LicenseHTML}} -</section> +<div class="gf-card"> + <div class="gf-readme-head">📄 License ({{.LicenseFile}})</div> + <div class="gf-readme-body markdown-body">{{.LicenseHTML}}</div> +</div> {{end}} `)) func (s *server) handleRepoView(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) { @@ -84,34 +151,66 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { } _, canAdminister := s.canAdminister(r, name) - tags, err := s.ops.ListRepoTags(name) + entries, found, err := s.ops.ListRepoTree(name, path) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + if !found && path != "" { + // A real path that doesn't exist — an empty repo at the root is + // handled below instead of 404ing (there's a legitimate page to + // show: the clone command to get started). + 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} + } - readmeContent, readmeFound, err := s.ops.GetRepoReadme(name) + branch, _, err := s.ops.GetRepoBranch(name) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - var readmeHTML template.HTML - if readmeFound { - readmeHTML, err = renderFileContent("README.md", readmeContent) + + var readmeHTML, licenseHTML template.HTML + var licenseFile string + var tags []string + if path == "" { + readmeContent, readmeFound, err := s.ops.GetRepoReadme(name) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } - } + if readmeFound { + readmeHTML, err = renderFileContent("README.md", readmeContent) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } - licenseContent, licenseFile, licenseFound, err := s.ops.GetRepoLicense(name) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var licenseHTML template.HTML - if licenseFound { - licenseHTML, err = renderFileContent(licenseFile, licenseContent) + licenseContent, foundLicenseFile, licenseFound, err := s.ops.GetRepoLicense(name) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if licenseFound { + licenseFile = foundLicenseFile + licenseHTML, err = renderFileContent(licenseFile, licenseContent) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + tags, err = s.ops.ListRepoTags(name) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -122,14 +221,92 @@ func (s *server) handleRepoView(w http.ResponseWriter, r *http.Request) { _ = repoTpl.Execute(&buf, struct { Repo store.Repo Domain string + CloneURL string + Branch string + Crumbs []crumb + Entries []treeEntryView + ShowUp bool + ParentPath string + Empty bool Tags []string ReadmeHTML template.HTML LicenseHTML template.HTML LicenseFile string CanAdminister bool Flash template.HTML - }{repo, s.domain, tags, readmeHTML, licenseHTML, licenseFile, canAdminister, flash(r)}) - s.render(w, r, name, "home", template.HTML(buf.String())) + }{ + repo, s.domain, "ssh://git@" + s.domain + ":2222/" + name + ".git", branch, + breadcrumbs(path), views, path != "", parentPath(path), path == "" && !found, + tags, readmeHTML, licenseHTML, licenseFile, canAdminister, 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}} +<div class="gf-crumbs"> + <a href="/r/{{.Repo}}">{{.Repo}}</a>{{range .Crumbs}} / {{if .Last}}{{.Name}}{{else}}<a href="/r/{{$.Repo}}?path={{.Path}}">{{.Name}}</a>{{end}}{{end}} +</div> +<div class="gf-card"> + <div class="gf-readme-body markdown-body"> + {{.Content}} + </div> +</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())) } func renderFileContent(filename, content string) (template.HTML, error) {
cmd/gitfed-web/handlers_search.go
diff --git a/cmd/gitfed-web/handlers_search.go b/cmd/gitfed-web/handlers_search.go new file mode 100644 index 0000000..0e54766 --- /dev/null +++ b/cmd/gitfed-web/handlers_search.go @@ -0,0 +1,78 @@ +package main + +import ( + "bytes" + "html/template" + "net/http" + "strings" + + "gitfed/internal/store" +) + +var searchTpl = template.Must(template.New("search").Parse(` +<h1>Search{{if .Query}}: "{{.Query}}"{{end}}</h1> +<form action="/search" method="get" style="max-width:420px;"> + <input type="search" name="q" value="{{.Query}}" placeholder="Search repos…" autofocus> +</form> +<table> +<tr><th>Repo</th><th>Owner</th><th>Topics</th></tr> +{{range .Results}} +<tr> + <td><a href="/r/{{.Name}}">{{.Name}}</a></td> + <td class="muted">{{.Owner}}</td> + <td>{{range .Topics}}<span class="badge plain">{{.}}</span>{{end}}</td> +</tr> +{{else}} +<tr><td colspan="3" class="muted">{{if .Query}}No matches.{{else}}Type something to search.{{end}}</td></tr> +{{end}} +</table> +`)) + +// searchVisible reports whether repo should be considered for search +// results for the given (possibly anonymous) session: same visibility rule +// as everywhere else — public repos to anyone, private ones only to an +// owner/collaborator. +func (s *server) searchVisible(r *http.Request, repo store.Repo) bool { + return s.canView(r, repo) +} + +func (s *server) handleSearch(w http.ResponseWriter, r *http.Request) { + q := strings.TrimSpace(r.URL.Query().Get("q")) + + var results []store.Repo + if q != "" { + all, err := s.ops.ListRepos() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + needle := strings.ToLower(q) + for _, repo := range all { + if !s.searchVisible(r, repo) { + continue + } + if matchesSearch(repo, needle) { + results = append(results, repo) + } + } + } + + var buf bytes.Buffer + _ = searchTpl.Execute(&buf, struct { + Query string + Results []store.Repo + }{q, results}) + s.render(w, r, "Search", "", template.HTML(buf.String())) +} + +func matchesSearch(repo store.Repo, lowerNeedle string) bool { + if strings.Contains(strings.ToLower(repo.Name), lowerNeedle) { + return true + } + for _, t := range repo.Topics { + if strings.Contains(strings.ToLower(t), lowerNeedle) { + return true + } + } + return false +}
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index ec5a7fb..518e719 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -5,6 +5,7 @@ import ( "html/template" "net/http" "strings" + "unicode" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" @@ -30,94 +31,198 @@ func renderMarkdown(src string) (template.HTML, error) { // forming a git fork, no gradient or curve. Used inline in the header (via // currentColor, so it follows the link color) and, percent-encoded, as the // favicon below. -const brandMark = `<svg class="brand-mark" width="20" height="20" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>` +const brandMark = `<svg class="brand-mark" width="18" height="18" viewBox="0 0 96 96" aria-hidden="true"><rect x="41" y="46" width="14" height="36" fill="currentColor"/><rect x="14" y="14" width="14" height="34" fill="currentColor" transform="rotate(35 21 31)"/><rect x="68" y="14" width="14" height="34" fill="currentColor" transform="rotate(-35 75 31)"/></svg>` const shellSrc = `<!doctype html> <html> <head> <meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{.Title}} — gitfed</title> <link rel="icon" media="(prefers-color-scheme: light)" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect x='41' y='46' width='14' height='36' fill='%2317181a'/%3E%3Crect x='14' y='14' width='14' height='34' fill='%2317181a' transform='rotate(35 21 31)'/%3E%3Crect x='68' y='14' width='14' height='34' fill='%2317181a' transform='rotate(-35 75 31)'/%3E%3C/svg%3E"> <link rel="icon" media="(prefers-color-scheme: dark)" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 96 96'%3E%3Crect x='41' y='46' width='14' height='36' fill='%23f0efec'/%3E%3Crect x='14' y='14' width='14' height='34' fill='%23f0efec' transform='rotate(35 21 31)'/%3E%3Crect x='68' y='14' width='14' height='34' fill='%23f0efec' transform='rotate(-35 75 31)'/%3E%3C/svg%3E"> <style> - body { font-family: -apple-system, system-ui, sans-serif; margin: 0; background: #0f1115; color: #e6e6e6; } - header { background: #171a21; padding: 0.75rem 1.5rem; display: flex; align-items: center; gap: 1.5rem; border-bottom: 1px solid #2a2f3a; } - header h1 { font-size: 1rem; margin: 0; } - header h1 a { color: #8ab4f8; text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem; } + :root { + --canvas: #0d0f13; --surface: #161a21; --surface-2: #1c212a; --surface-3: #232933; + --border: #262c36; --border-strong: #333a46; + --text: #e8eaed; --text-dim: #9aa1ac; --text-faint: #6b7280; + --accent: #6c9df5; --accent-dim: #3a5a8f; --accent-ink: #0d0f13; + --ok-bg: rgba(95,191,143,0.14); --ok-fg: #7bd6a8; + --pending-bg: rgba(224,179,78,0.14); --pending-fg: #e0b34e; + --danger-bg: rgba(242,139,130,0.14); --danger-fg: #f28b82; + --mono: "SF Mono", "IBM Plex Mono", ui-monospace, Menlo, Consolas, monospace; + } + * { box-sizing: border-box; } + body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--canvas); color: var(--text); font-size: 15px; line-height: 1.5; -webkit-font-smoothing: antialiased; } + a { color: inherit; } + + /* ---------- nav ---------- */ + .gf-nav { display: flex; align-items: center; gap: 0.5rem; height: 52px; padding: 0 1rem; background: var(--surface); border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; } + .gf-nav-brand { display: inline-flex; align-items: center; gap: 0.5rem; color: var(--accent); text-decoration: none; font-weight: 700; font-size: 0.95rem; flex-shrink: 0; } .brand-mark { flex-shrink: 0; } - nav { display: flex; gap: 1rem; flex: 1; } - nav a { color: #cfd3dc; text-decoration: none; font-size: 0.9rem; } - nav a.active { color: #8ab4f8; font-weight: bold; } - nav form { margin: 0; } - nav button.linklike { background: none; border: none; color: #cfd3dc; font-size: 0.9rem; cursor: pointer; padding: 0; font-family: inherit; } - main { padding: 1.5rem; max-width: 960px; margin: 0 auto; } - table { border-collapse: collapse; width: 100%; margin: 1rem 0; } - th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #2a2f3a; font-size: 0.9rem; } - th { color: #9aa1ac; font-weight: 600; } + .gf-nav-burger { display: none; background: none; border: none; color: var(--text-dim); padding: 0.4rem; cursor: pointer; margin-left: auto; } + .gf-nav-panel { display: flex; align-items: center; gap: 0.75rem; flex: 1; margin-left: 0.75rem; min-width: 0; } + .gf-nav-links { display: flex; align-items: center; gap: 0.15rem; flex-shrink: 0; } + .gf-nav-links a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; padding: 0.4rem 0.6rem; border-radius: 6px; } + .gf-nav-links a:hover { color: var(--text); } + .gf-nav-links a.active { color: var(--text); background: var(--surface-2); font-weight: 600; } + .gf-nav-search { flex: 1; display: flex; justify-content: center; margin: 0; } + .gf-nav-search-inner { display: flex; align-items: center; gap: 0.5rem; width: 100%; max-width: 340px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.38rem 0.65rem; color: var(--text-faint); } + .gf-nav-search-inner:focus-within { border-color: var(--accent); } + .gf-nav-search input { border: none; background: none; color: var(--text); font-size: 0.84rem; padding: 0; margin: 0; flex: 1; min-width: 0; } + .gf-nav-search input:focus { outline: none; } + .gf-nav-search input::placeholder { color: var(--text-faint); } + .gf-nav-search kbd { font-family: var(--mono); font-size: 0.66rem; background: var(--surface-3); border: 1px solid var(--border-strong); border-radius: 4px; padding: 0.05rem 0.35rem; color: var(--text-faint); flex-shrink: 0; } + .gf-nav-right { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; } + .gf-nav-right a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; } + .gf-nav-right a:hover { color: var(--text); } + .gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; } + .linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; } + .linklike:hover { color: var(--text); } + + @media (max-width: 720px) { + .gf-nav-burger { display: flex; align-items: center; } + .gf-nav-panel { display: none; position: absolute; top: 52px; left: 0; right: 0; background: var(--surface); border-bottom: 1px solid var(--border); flex-direction: column; align-items: stretch; gap: 0.75rem; padding: 0.85rem 1rem; margin: 0; } + .gf-nav-panel.open { display: flex; } + .gf-nav-links { flex-direction: column; align-items: stretch; gap: 0.15rem; } + .gf-nav-search-inner { max-width: none; } + .gf-nav-right { justify-content: space-between; } + } + + /* ---------- layout ---------- */ + main { padding: 1.5rem 1rem; max-width: 980px; margin: 0 auto; } + section { margin-bottom: 2rem; } + + /* ---------- repo page ---------- */ + .gf-crumbs { font-family: var(--mono); font-size: 0.84rem; color: var(--text-dim); } + .gf-crumbs a { color: var(--text-dim); text-decoration: none; } + .gf-crumbs a:hover { color: var(--text); } + .gf-crumbs .sep { color: var(--text-faint); margin: 0 0.35em; } + .gf-repo-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-top: 0.6rem; } + .gf-repo-head h1 { font-size: 1.3rem; margin: 0; font-weight: 700; } + .gf-action-bar { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; margin: 1rem 0; } + .gf-action-bar .gf-btn, .gf-action-bar .gf-clone-url { margin-top: 0; } + .gf-clone-url { + flex: 1; min-width: 200px; font-family: var(--mono); font-size: 0.8rem; color: var(--text-dim); + background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 0.4rem 0.7rem; + display: flex; align-items: center; gap: 0.5rem; overflow: hidden; + } + .gf-clone-url code { background: none; padding: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .gf-clone-url button { margin: 0 0 0 auto; padding: 0; background: none; border: none; color: var(--text-faint); cursor: pointer; flex-shrink: 0; font-size: 0.95rem; } + .gf-clone-url button:hover { color: var(--text); } + .gf-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; } + .gf-file-table { width: 100%; border-collapse: collapse; margin: 0; } + .gf-file-table tr { border-bottom: 1px solid var(--border); } + .gf-file-table tr:last-child { border-bottom: none; } + .gf-file-table td { padding: 0.55rem 0.9rem; font-size: 0.86rem; vertical-align: middle; border: none; } + .gf-file-table td.icon { width: 1.4rem; padding-right: 0; } + .gf-file-table td.name { font-family: var(--mono); font-size: 0.84rem; } + .gf-file-table td.name a { color: var(--text); text-decoration: none; } + .gf-file-table td.name a:hover { color: var(--accent); text-decoration: underline; } + .gf-file-table td.meta { color: var(--text-faint); font-size: 0.78rem; text-align: right; white-space: nowrap; } + .gf-readme-head { display: flex; align-items: center; gap: 0.5rem; padding: 0.75rem 1.1rem; border-bottom: 1px solid var(--border); font-size: 0.86rem; color: var(--text-dim); font-family: var(--mono); } + .gf-readme-body { padding: 1.3rem; } + + @media (max-width: 620px) { + .gf-action-bar { flex-direction: column; align-items: stretch; } + .gf-clone-url { order: 2; } + .gf-file-table td.meta { display: none; } + } + + /* ---------- tables ---------- */ + .table-wrap { overflow-x: auto; } + table { border-collapse: collapse; width: 100%; margin: 1rem 0; display: block; overflow-x: auto; max-width: 100%; } + th, td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); font-size: 0.88rem; } + th { color: var(--text-dim); font-weight: 600; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.02em; } + tr:hover td { background: var(--surface-2); } + + /* ---------- forms & buttons ---------- */ form.inline { display: inline; } - form.card { background: #171a21; border: 1px solid #2a2f3a; border-radius: 8px; padding: 1rem; margin: 1rem 0; max-width: 480px; } - form.card label { display: block; font-size: 0.85rem; color: #9aa1ac; margin-top: 0.6rem; } - input, select { width: 100%; box-sizing: border-box; padding: 0.4rem; margin-top: 0.2rem; background: #0f1115; border: 1px solid #333944; color: #e6e6e6; border-radius: 4px; } - button { margin-top: 0.8rem; padding: 0.4rem 0.9rem; background: #8ab4f8; border: none; border-radius: 4px; color: #0f1115; font-weight: 600; cursor: pointer; } - button.danger { background: #f28b82; } - .msg { padding: 0.6rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; } - .msg.ok { background: #1e3a2a; color: #9ae6b4; } - .msg.err { background: #3a1e1e; color: #f28b82; } - .badge { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 10px; font-size: 0.75rem; } - .badge.pending { background: #3a3320; color: #f5cf5b; } - .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; font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace; font-size: 0.86em; } + form.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; margin: 1rem 0; max-width: 480px; } + form.card label { display: block; font-size: 0.85rem; color: var(--text-dim); margin-top: 0.6rem; } + input, select { width: 100%; box-sizing: border-box; padding: 0.45rem 0.6rem; margin-top: 0.25rem; background: var(--canvas); border: 1px solid var(--border-strong); color: var(--text); border-radius: 6px; font-size: 0.9rem; font-family: inherit; } + input:focus, select:focus { outline: none; border-color: var(--accent); } + button, .gf-btn { margin-top: 0.8rem; padding: 0.45rem 0.9rem; background: var(--surface-2); border: 1px solid var(--border-strong); border-radius: 7px; color: var(--text); font-weight: 600; font-size: 0.86rem; cursor: pointer; display: inline-flex; align-items: center; gap: 0.4rem; text-decoration: none; } + button:hover, .gf-btn:hover { background: var(--surface-3); } + button[type="submit"]:not(.linklike), .gf-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } + button[type="submit"]:not(.linklike):hover, .gf-btn.primary:hover { filter: brightness(1.08); } + button.danger { background: var(--danger-bg); border-color: transparent; color: var(--danger-fg); } + button.danger:hover { background: rgba(242,139,130,0.24); } + + /* ---------- misc components ---------- */ + .msg { padding: 0.6rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-size: 0.9rem; } + .msg.ok { background: var(--ok-bg); color: var(--ok-fg); } + .msg.err { background: var(--danger-bg); color: var(--danger-fg); } + .badge { display: inline-block; padding: 0.12rem 0.55rem; border-radius: 999px; font-size: 0.72rem; font-family: var(--mono); } + .badge.pending { background: var(--pending-bg); color: var(--pending-fg); } + .badge.trusted { background: var(--ok-bg); color: var(--ok-fg); } + .badge.plain { background: var(--surface-2); border: 1px solid var(--border); color: var(--text-dim); margin-right: 0.3rem; } + .muted { color: var(--text-dim); font-size: 0.85rem; } + code, pre { background: var(--surface-2); border-radius: 6px; font-family: var(--mono); 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 { padding: 1rem; overflow-x: auto; border: 1px solid var(--border); 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, .markdown-body h2, .markdown-body h3, .markdown-body h4 { border-bottom: 1px solid var(--border); 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 a { color: var(--accent); } .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 hr { border: none; border-top: 1px solid var(--border); margin: 1.8rem 0; } + .markdown-body blockquote { margin-left: 0; padding: 0.2rem 1rem; border-left: 3px solid var(--border-strong); color: var(--text-dim); } .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 th, .markdown-body td { border: 1px solid var(--border); padding: 0.4rem 0.8rem; text-align: left; } + .markdown-body th { background: var(--surface-2); } .markdown-body input[type="checkbox"] { width: auto; margin: 0 0.4em 0 0; } - footer { max-width: 960px; margin: 2rem auto 1.5rem; padding: 0 1.5rem; } - footer a { color: #6b7078; font-size: 0.78rem; text-decoration: none; } - footer a:hover { color: #9aa1ac; } + + footer { max-width: 980px; margin: 2rem auto 1.5rem; padding: 0 1rem; } + footer a { color: var(--text-faint); font-size: 0.78rem; text-decoration: none; } + footer a:hover { color: var(--text-dim); } + + @media (max-width: 480px) { + main { padding: 1rem 0.85rem; } + form.card { max-width: none; } + } </style> </head> <body> -<header> - <h1><a href="/">{{.BrandMark}} gitfed — {{.Domain}}</a></h1> - <nav> - <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a> - {{if .LoggedIn}} - <a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a> - <a href="/settings"{{if eq .Active "settings"}} class="active"{{end}}>Settings</a> - {{if .IsAdmin}}<a href="/admin"{{if eq .Active "admin"}} class="active"{{end}}>Admin</a>{{end}} - {{end}} - </nav> - {{if .LoggedIn}} - <span class="muted">{{.Username}}</span> - <form method="post" action="/logout"><button class="linklike" type="submit">Log out</button></form> - {{else}} - <a href="/login">Log in</a> - {{end}} +<header class="gf-nav"> + <a href="/" class="gf-nav-brand">{{.BrandMark}}<span>gitfed</span></a> + <button class="gf-nav-burger" id="navBurger" aria-label="Toggle menu" aria-expanded="false" aria-controls="navPanel"> + <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M3 5h14M3 10h14M3 15h14"/></svg> + </button> + <div class="gf-nav-panel" id="navPanel"> + <nav class="gf-nav-links"> + <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a> + {{if .LoggedIn}} + <a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a> + <a href="/settings"{{if eq .Active "settings"}} class="active"{{end}}>Settings</a> + {{if .IsAdmin}}<a href="/admin"{{if eq .Active "admin"}} class="active"{{end}}>Admin</a>{{end}} + {{end}} + </nav> + <form class="gf-nav-search" action="/search" method="get" role="search"> + <div class="gf-nav-search-inner"> + <svg width="14" height="14" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true"><circle cx="9" cy="9" r="6.5"/><line x1="14" y1="14" x2="18" y2="18"/></svg> + <input type="search" name="q" id="navSearch" placeholder="Search repos…" value="{{.SearchQuery}}" autocomplete="off"> + <kbd>⌘K</kbd> + </div> + </form> + <div class="gf-nav-right"> + {{if .LoggedIn}} + <span class="gf-avatar" title="{{.Username}}">{{.Initials}}</span> + <form method="post" action="/logout"><button class="linklike" type="submit">Log out</button></form> + {{else}} + <a href="/login">Log in</a> + {{end}} + </div> + </div> </header> <main> {{.Body}} @@ -125,6 +230,33 @@ const shellSrc = `<!doctype html> <footer> <a href="/changelog">gitfed {{.Version}}</a> </footer> +<script> +(function () { + var burger = document.getElementById('navBurger'); + var panel = document.getElementById('navPanel'); + if (burger && panel) { + burger.addEventListener('click', function () { + var open = panel.classList.toggle('open'); + burger.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + } + document.addEventListener('keydown', function (e) { + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { + var s = document.getElementById('navSearch'); + if (s) { e.preventDefault(); s.focus(); } + } + }); + document.addEventListener('click', function (e) { + var btn = e.target.closest('[data-copy]'); + if (!btn || !navigator.clipboard) return; + navigator.clipboard.writeText(btn.getAttribute('data-copy')).then(function () { + var orig = btn.textContent; + btn.textContent = '✓'; + setTimeout(function () { btn.textContent = orig; }, 1200); + }); + }); +})(); +</script> </body> </html>` @@ -134,10 +266,31 @@ func (s *server) render(w http.ResponseWriter, r *http.Request, title, active st sess, loggedIn := s.currentSession(r) w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = shellTpl.Execute(w, struct { - Title, Domain, Active, Username, Version string - LoggedIn, IsAdmin bool - Body, BrandMark template.HTML - }{title, s.domain, active, sess.Username, version.Version, loggedIn, sess.IsAdmin, body, template.HTML(brandMark)}) + Title, Domain, Active, Username, Version, Initials, SearchQuery string + LoggedIn, IsAdmin bool + Body, BrandMark template.HTML + }{title, s.domain, active, sess.Username, version.Version, initials(sess.Username), r.URL.Query().Get("q"), loggedIn, sess.IsAdmin, body, template.HTML(brandMark)}) +} + +// initials turns a username into a one-or-two-letter avatar label: +// "bastien-mrq" -> "BM", "alice" -> "AL". +func initials(username string) string { + var parts []string + for _, p := range strings.FieldsFunc(username, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) { + if p != "" { + parts = append(parts, p) + } + } + switch { + case len(parts) >= 2: + return strings.ToUpper(parts[0][:1] + parts[1][:1]) + case len(parts) == 1 && len(parts[0]) >= 2: + return strings.ToUpper(parts[0][:2]) + case len(parts) == 1: + return strings.ToUpper(parts[0]) + default: + return "?" + } } // flash renders the ?msg=&err= query params (set by handlers that redirect
cmd/gitfed-web/routes.go
diff --git a/cmd/gitfed-web/routes.go b/cmd/gitfed-web/routes.go index bb01302..62d5117 100644 --- a/cmd/gitfed-web/routes.go +++ b/cmd/gitfed-web/routes.go @@ -6,12 +6,12 @@ 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) mux.HandleFunc("GET /changelog", s.handleChangelog) + mux.HandleFunc("GET /search", s.handleSearch) // Self-service — any logged-in user, scoped to their own stuff via // CheckAccess inside the handlers.
internal/admin/admin.go
diff --git a/internal/admin/admin.go b/internal/admin/admin.go index a81706c..fb954ca 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -50,6 +50,7 @@ type Ops interface { 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) + GetRepoBranch(name string) (branch string, found bool, err error) GetACL(repoName string) (store.ACL, error) GrantCollaborator(repoName, principal string, role store.Role) error @@ -274,6 +275,15 @@ func (a *Admin) GetRepoFile(name, path string) (string, bool, error) { return gitexec.ReadFileAtHEAD(repo.Path, path) } +func (a *Admin) GetRepoBranch(name string) (string, bool, error) { + repo, err := a.Store.GetRepo(name) + if err != nil { + return "", false, err + } + branch, ok := gitexec.DefaultBranchName(repo.Path) + return branch, ok, nil +} + // 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 80a64b0..2254eab 100644 --- a/internal/adminrpc/client.go +++ b/internal/adminrpc/client.go @@ -184,6 +184,12 @@ func (c *Client) GetRepoFile(name, path string) (string, bool, error) { return out.Content, out.Found, err } +func (c *Client) GetRepoBranch(name string) (string, bool, error) { + var out branchResult + err := c.call(methodGetRepoBranch, nameArgs{Name: name}, &out) + return out.Branch, 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 678818e..34a1bf0 100644 --- a/internal/adminrpc/protocol.go +++ b/internal/adminrpc/protocol.go @@ -36,6 +36,7 @@ const ( methodListRepoTags = "ListRepoTags" methodListRepoTree = "ListRepoTree" methodGetRepoFile = "GetRepoFile" + methodGetRepoBranch = "GetRepoBranch" methodGetUser = "GetUser" methodSetUserAdmin = "SetUserAdmin" methodSetPassword = "SetPassword" @@ -142,6 +143,11 @@ type fileResult struct { Found bool `json:"found"` } +type branchResult struct { + Branch string `json:"branch"` + 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 319103a..a5a4092 100644 --- a/internal/adminrpc/server.go +++ b/internal/adminrpc/server.go @@ -217,6 +217,14 @@ func (s *Server) dispatch(req wireRequest) (any, error) { content, found, err := s.ops.GetRepoFile(a.Name, a.Path) return fileResult{Content: content, Found: found}, err + case methodGetRepoBranch: + var a nameArgs + if err := json.Unmarshal(req.Args, &a); err != nil { + return nil, err + } + branch, found, err := s.ops.GetRepoBranch(a.Name) + return branchResult{Branch: branch, 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 c300b2a..2482b25 100644 --- a/internal/gitexec/gitexec.go +++ b/internal/gitexec/gitexec.go @@ -141,6 +141,24 @@ func resolveDefaultRef(repoPath string) (ref string, ok bool) { return refs[0], true } +// DefaultBranchName returns the short branch name (e.g. "main") backing +// resolveDefaultRef, for display purposes. ok is false under the same +// conditions as resolveDefaultRef. +func DefaultBranchName(repoPath string) (string, bool) { + ref, ok := resolveDefaultRef(repoPath) + if !ok { + return "", false + } + if ref == "HEAD" { + out, err := exec.Command("git", "--git-dir="+repoPath, "symbolic-ref", "--short", "HEAD").Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(out)), true + } + return strings.TrimPrefix(ref, "refs/heads/"), true +} + // ReadFileAtHEAD returns the content of filename as it exists in the tree at // the repo's default branch (see resolveDefaultRef). found is false (with a // nil error) if the repo has no commits yet or the file doesn't exist there
internal/gitexec/gitexec_test.go
diff --git a/internal/gitexec/gitexec_test.go b/internal/gitexec/gitexec_test.go index 189e0cb..054d7cf 100644 --- a/internal/gitexec/gitexec_test.go +++ b/internal/gitexec/gitexec_test.go @@ -181,4 +181,46 @@ func TestPushToMismatchedDefaultBranch(t *testing.T) { if !found || len(root) != 1 || root[0].Name != "README.md" { t.Fatalf("ListTree: found=%v root=%+v, want [README.md]", found, root) } + + branch, ok := DefaultBranchName(barePath) + if !ok || branch != "trunk" { + t.Fatalf("DefaultBranchName: got %q ok=%v, want \"trunk\"", branch, ok) + } +} + +func TestDefaultBranchNameViaHEAD(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + + work := filepath.Join(tmp, "work") + if err := os.Mkdir(work, 0755); err != nil { + t.Fatal(err) + } + run(t, work, "git", "init", "-q", "-b", "main") + if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + run(t, work, "git", "add", "f") + run(t, work, "git", "commit", "-q", "-m", "initial") + run(t, work, "git", "remote", "add", "origin", barePath) + run(t, work, "git", "push", "-q", "origin", "main") + + branch, ok := DefaultBranchName(barePath) + if !ok || branch != "main" { + t.Fatalf("DefaultBranchName: got %q ok=%v, want \"main\"", branch, ok) + } +} + +func TestDefaultBranchNameEmptyRepo(t *testing.T) { + tmp := t.TempDir() + barePath := filepath.Join(tmp, "repo.git") + if err := InitBareRepo(barePath); err != nil { + t.Fatalf("init bare repo: %v", err) + } + if _, ok := DefaultBranchName(barePath); ok { + t.Fatal("empty repo should not report a default branch") + } }