use gitweb/gitolite directory layout (including subdirs)
Alan Pearce alan@alanpearce.eu
Sun, 30 Mar 2025 21:12:17 +0200
16 files changed, 354 insertions(+), 245 deletions(-)
M config.yaml → config.yaml
@@ -1,5 +1,5 @@ repo: - scanPath: /tmp/git + root: /tmp/gitolite readme: - readme - README
M config/config.go → config/config.go
@@ -9,10 +9,9 @@ "gopkg.in/yaml.v3" ) type Repo struct { - ScanPath string `yaml:"scanPath"` + Root string `yaml:"root"` Readme []string `yaml:"readme"` MainBranch []string `yaml:"mainBranch"` - Ignore []string `yaml:"ignore,omitempty"` Unlisted []string `yaml:"unlisted,omitempty"` } @@ -50,9 +49,6 @@ if err := yaml.Unmarshal(b, &c); err != nil { return nil, fmt.Errorf("parsing config: %w", err) } - if c.Repo.ScanPath, err = filepath.Abs(c.Repo.ScanPath); err != nil { - return nil, err - } if c.Dirs.Static, err = filepath.Abs(c.Dirs.Static); err != nil { return nil, err }
A data/entries.go
@@ -0,0 +1,65 @@+package data + +import ( + "sort" + "time" +) + +type Repository struct { + Name string + Category string + Path string + Slug string + Description string + LastCommit time.Time +} + +type Entry struct { + Name string + LastCommit time.Time + Repositories []*Repository +} + +type Entries struct { + Children []*Entry + Map map[string]*Entry +} + +func (ent *Entries) Add(r Repository) { + if r.Category == "" { + ent.Children = append(ent.Children, &Entry{ + Name: r.Name, + LastCommit: r.LastCommit, + Repositories: []*Repository{&r}, + }) + return + } + t, ok := ent.Map[r.Category] + if !ok { + t := &Entry{ + Name: r.Category, + LastCommit: r.LastCommit, + Repositories: []*Repository{&r}, + } + ent.Map[r.Category] = t + return + } + + if t.LastCommit.IsZero() || t.LastCommit.Before(r.LastCommit) { + t.LastCommit = r.LastCommit + } + + t.Repositories = append(t.Repositories, &r) +} + +func (ent *Entries) Sort() { + sort.Slice(ent.Children, func(i, j int) bool { + return ent.Children[i].LastCommit.After(ent.Children[j].LastCommit) + }) + + for _, entries := range ent.Map { + sort.Slice(entries.Repositories, func(i, j int) bool { + return entries.Repositories[i].LastCommit.After(entries.Repositories[j].LastCommit) + }) + } +}
M git/tree.go → git/tree.go
@@ -2,11 +2,13 @@ package git import ( "fmt" + "strings" "github.com/go-git/go-git/v5/plumbing/object" ) func (g *GitRepo) FileTree(path string) ([]NiceTree, error) { + path = strings.TrimSuffix(path, "/") c, err := g.r.CommitObject(g.h) if err != nil { return nil, fmt.Errorf("commit object: %w", err)
M go.mod → go.mod
@@ -20,6 +20,7 @@ github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/acomagu/bufpipe v1.0.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cloudflare/circl v1.6.0 // indirect + github.com/dimfeld/httptreemux/v5 v5.5.0 // indirect github.com/dlclark/regexp2 v1.11.4 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
M go.sum → go.sum
@@ -30,6 +30,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dimfeld/httptreemux/v5 v5.5.0 h1:p8jkiMrCuZ0CmhwYLcbNbl7DDo21fozhKHQ2PccwOFQ= +github.com/dimfeld/httptreemux/v5 v5.5.0/go.mod h1:QeEylH57C0v3VO0tkKraVz9oD3Uu93CKPnTLbsidvSw= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
M readme → readme
@@ -6,6 +6,9 @@ FORK CHANGES • Uses gomponents instead of html/template. +• Better integration with [gitolite](https://gitolite.com/gitolite/index.html) +• repo.ignore is ignored: only repositories listed in projects.list are shown. +• Supports subdirectories FEATURES @@ -27,7 +30,7 @@ Example config.yaml: repo: - scanPath: /var/www/git + root: /var/lib/gitolite readme: - readme - README @@ -52,13 +55,12 @@ port: 5555 These options are fairly self-explanatory, but of note are: -• repo.scanPath: where all your git repos live (or die). elgit doesn't - traverse subdirs yet. +• repo.root: where all your git repos live (or die). This should contain projects.list + and repositories/ • dirs: use this to override the static assets. • repo.readme: readme files to look for. • repo.mainBranch: main branch names to look for. -• repo.ignore: repos to ignore, relative to scanPath. -• repo.unlisted: repos to hide, relative to scanPath. +• repo.unlisted: repos to hide, relative to root/repositories. • server.name: used for go-import meta tags and clone URLs. • meta.syntaxHighlight: this is used to select the syntax theme to render. If left blank or removed, the native theme will be used. If an invalid theme is set in this field,
M routes/git.go → routes/git.go
@@ -5,19 +5,17 @@ "compress/gzip" "io" "log" "net/http" - "path/filepath" + "path" - securejoin "github.com/cyphar/filepath-securejoin" "go.alanpearce.eu/elgit/git/service" ) -func (d *deps) InfoRefs(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - name = filepath.Clean(name) +func (d *deps) InfoRefs(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + repo, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -37,13 +35,12 @@ return } } -func (d *deps) UploadPack(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - name = filepath.Clean(name) +func (d *deps) UploadPack(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) - repo, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + repo, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return }
M routes/handler.go → routes/handler.go
@@ -3,14 +3,26 @@ import ( "log" "net/http" + "os" + "path" + "slices" + "strings" + + "github.com/dimfeld/httptreemux/v5" "go.alanpearce.eu/elgit/config" ) // Checks for gitprotocol-http(5) specific smells; if found, passes // the request on to the git http service, else render the web frontend. -func (d *deps) Multiplex(w http.ResponseWriter, r *http.Request) { - path := r.PathValue("rest") +func (d *deps) Multiplex(w http.ResponseWriter, r *http.Request, params map[string]string) { + rest := params["rest"] + switch params["name"] { + case "info", "git-upload-pack": + rest = path.Join(params["name"], params["rest"]) + params["name"] = params["category"] + params["category"] = "" + } if r.URL.RawQuery == "service=git-receive-pack" { w.WriteHeader(http.StatusBadRequest) @@ -25,33 +37,76 @@ return } - if path == "info/refs" && + if rest == "info/refs" && r.URL.RawQuery == "service=git-upload-pack" && r.Method == "GET" { - d.InfoRefs(w, r) - } else if path == "git-upload-pack" && r.Method == "POST" { - d.UploadPack(w, r) + d.InfoRefs(w, r, params) + } else if rest == "git-upload-pack" && r.Method == "POST" { + d.UploadPack(w, r, params) } else if r.Method == "GET" { - d.RepoIndex(w, r) + d.RepoIndex(w, r, params) } } -func Handlers(c *config.Config) *http.ServeMux { - mux := http.NewServeMux() - d := deps{c} +func Handlers(c *config.Config) *httptreemux.TreeMux { + mux := httptreemux.New() + + projects, err := ReadProjectsList(c) + if err != nil { + log.Fatal(err) + } + + d := deps{ + c, + projects, + } + + categories := []string{} + for _, project := range projects { + if cat, _, found := strings.Cut(project, string(os.PathSeparator)); found { + categories = append(categories, cat) + } + } + categories = slices.Compact(categories) + + mux.NotFoundHandler = func(w http.ResponseWriter, r *http.Request) { + d.Write404(w) + } + + mux.RedirectTrailingSlash = false + + mux.GET("/", d.Index) + mux.GET("/static/:file", d.ServeStatic) + + mux.GET("/:name/tree/:ref/*rest", d.RepoTree) + mux.GET("/:name/blob/:ref/*rest", d.FileContent) + mux.GET("/:name/tree/:ref/", d.RepoTree) + mux.GET("/:name/blob/:ref/", d.FileContent) + mux.GET("/:name/log/:ref", d.Log) + mux.GET("/:name/archive/:file", d.Archive) + mux.GET("/:name/commit/:ref", d.Diff) + mux.GET("/:name/refs/", d.Refs) + mux.GET("/:name", d.Multiplex) + mux.POST("/:name", d.Multiplex) + mux.GET("/:name/", d.Multiplex) + mux.POST("/:name/", d.Multiplex) + mux.GET("/:name/*rest", d.Multiplex) + mux.POST("/:name/*rest", d.Multiplex) - mux.HandleFunc("GET /", d.Index) - mux.HandleFunc("GET /static/{file}", d.ServeStatic) - mux.HandleFunc("GET /{name}", d.Multiplex) - mux.HandleFunc("POST /{name}", d.Multiplex) - mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", d.RepoTree) - mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", d.FileContent) - mux.HandleFunc("GET /{name}/log/{ref}", d.Log) - mux.HandleFunc("GET /{name}/archive/{file}", d.Archive) - mux.HandleFunc("GET /{name}/commit/{ref}", d.Diff) - mux.HandleFunc("GET /{name}/refs/{$}", d.Refs) - mux.HandleFunc("GET /{name}/{rest...}", d.Multiplex) - mux.HandleFunc("POST /{name}/{rest...}", d.Multiplex) + mux.GET("/:category/:name/tree/:ref/*rest", d.RepoTree) + mux.GET("/:category/:name/blob/:ref/*rest", d.FileContent) + mux.GET("/:category/:name/tree/:ref/", d.RepoTree) + mux.GET("/:category/:name/blob/:ref/", d.FileContent) + mux.GET("/:category/:name/log/:ref", d.Log) + mux.GET("/:category/:name/archive/:file", d.Archive) + mux.GET("/:category/:name/commit/:ref", d.Diff) + mux.GET("/:category/:name/refs/", d.Refs) + mux.GET("/:category/:name", d.Multiplex) + mux.POST("/:category/:name", d.Multiplex) + mux.GET("/:category/:name/", d.Multiplex) + mux.POST("/:category/:name/", d.Multiplex) + mux.GET("/:category/:name/*rest", d.Multiplex) + mux.POST("/:category/:name/*rest", d.Multiplex) return mux }
M routes/routes.go → routes/routes.go
@@ -5,15 +5,13 @@ "compress/gzip" "fmt" "log" "net/http" - "os" + "path" "path/filepath" - "sort" "strconv" "strings" - "time" securejoin "github.com/cyphar/filepath-securejoin" - "github.com/dustin/go-humanize" + "github.com/dimfeld/httptreemux/v5" "github.com/microcosm-cc/bluemonday" "github.com/russross/blackfriday/v2" "go.alanpearce.eu/elgit/config" @@ -22,95 +20,33 @@ "go.alanpearce.eu/elgit/templates" ) type deps struct { - c *config.Config + c *config.Config + projects []string } -func (d *deps) Index(w http.ResponseWriter, r *http.Request) { - dirs, err := os.ReadDir(d.c.Repo.ScanPath) - if err != nil { - d.Write500(w) - log.Printf("reading scan path: %s", err) - return - } - - type info struct { - DisplayName, Name, Desc, Idle string - d time.Time - } - - infos := []info{} - - for _, dir := range dirs { - name := dir.Name() - if !dir.IsDir() || d.isIgnored(name) || d.isUnlisted(name) { - continue - } - - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) - if err != nil { - log.Printf("securejoin error: %v", err) - d.Write404(w) - return - } - - gr, err := git.Open(path, "") - if err != nil { - log.Println(err) - continue - } - - c, err := gr.LastCommit() - if err != nil { - d.Write500(w) - log.Println(err) - return - } - - infos = append(infos, info{ - DisplayName: getDisplayName(name), - Name: name, - Desc: getDescription(path), - Idle: humanize.Time(c.Author.When), - d: c.Author.When, - }) - } - - sort.Slice(infos, func(i, j int) bool { - return infos[j].d.Before(infos[i].d) - }) - - // Convert to the format expected by the templates package - repoInfos := make([]templates.RepoInfo, len(infos)) - for i, info := range infos { - repoInfos[i] = templates.RepoInfo{ - DisplayName: info.DisplayName, - Name: info.Name, - Desc: info.Desc, - Idle: info.Idle, - LastCommit: info.d, - } - } +func (d *deps) Index(w http.ResponseWriter, r *http.Request, params map[string]string) { + repos := d.getAllRepos() pageData := templates.PageData{ Meta: d.c.Meta, } - if err := templates.Index(pageData, repoInfos).Render(w); err != nil { + if err := templates.Index(pageData, repos).Render(w); err != nil { log.Println(err) return } } -func (d *deps) RepoIndex(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if d.isIgnored(name) { +func (d *deps) RepoIndex(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) + + if d.isNotAllowed(name) { d.Write404(w) return } - name = filepath.Clean(name) - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -179,19 +115,18 @@ log.Println(err) } } -func (d *deps) RepoTree(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if d.isIgnored(name) { +func (d *deps) RepoTree(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) + if d.isNotAllowed(name) { d.Write404(w) return } - treePath := r.PathValue("rest") - ref := r.PathValue("ref") + treePath := strings.TrimSuffix(params["rest"], "/") + ref := params["ref"] - name = filepath.Clean(name) - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -219,24 +154,24 @@ d.listFiles(files, data, w) } -func (d *deps) FileContent(w http.ResponseWriter, r *http.Request) { +func (d *deps) FileContent(w http.ResponseWriter, r *http.Request, params map[string]string) { var raw bool if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil { raw = rawParam } - name := r.PathValue("name") - if d.isIgnored(name) { + name := path.Join(params["category"], params["name"]) + if d.isNotAllowed(name) { d.Write404(w) return } - treePath := r.PathValue("rest") - ref := r.PathValue("ref") + treePath := params["rest"] + ref := params["ref"] - name = filepath.Clean(name) - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + name = httptreemux.Clean(name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -270,14 +205,14 @@ } } } -func (d *deps) Archive(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if d.isIgnored(name) { +func (d *deps) Archive(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) + if d.isNotAllowed(name) { d.Write404(w) return } - file := r.PathValue("file") + file := params["file"] // TODO: extend this to add more files compression (e.g.: xz) if !strings.HasSuffix(file, ".tar.gz") { @@ -293,9 +228,9 @@ filename := fmt.Sprintf("%s-%s.tar.gz", name, ref) setContentDisposition(w, filename) setGZipMIME(w) - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -327,17 +262,17 @@ return } } -func (d *deps) Log(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if d.isIgnored(name) { +func (d *deps) Log(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) + if d.isNotAllowed(name) { d.Write404(w) return } - ref := r.PathValue("ref") + ref := params["ref"] - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -370,17 +305,17 @@ return } } -func (d *deps) Diff(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if d.isIgnored(name) { +func (d *deps) Diff(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) + if d.isNotAllowed(name) { d.Write404(w) return } - ref := r.PathValue("ref") + ref := params["ref"] - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -413,16 +348,16 @@ return } } -func (d *deps) Refs(w http.ResponseWriter, r *http.Request) { - name := r.PathValue("name") - if d.isIgnored(name) { +func (d *deps) Refs(w http.ResponseWriter, r *http.Request, params map[string]string) { + name := path.Join(params["category"], params["name"]) + if d.isNotAllowed(name) { d.Write404(w) return } - path, err := securejoin.SecureJoin(d.c.Repo.ScanPath, name) + path, err := d.GetCleanPath(name) if err != nil { - log.Printf("securejoin error: %v", err) + log.Printf("getcleanpath error: %v", err) d.Write404(w) return } @@ -459,9 +394,9 @@ return } } -func (d *deps) ServeStatic(w http.ResponseWriter, r *http.Request) { - f := r.PathValue("file") - f = filepath.Clean(f) +func (d *deps) ServeStatic(w http.ResponseWriter, r *http.Request, params map[string]string) { + f := params["file"] + f = httptreemux.Clean(f) f, err := securejoin.SecureJoin(d.c.Dirs.Static, f) if err != nil { d.Write404(w)
M routes/util.go → routes/util.go
@@ -1,13 +1,18 @@ package routes import ( - "io/fs" + "bytes" "log" "net/http" "os" "path/filepath" + "slices" "strings" + securejoin "github.com/cyphar/filepath-securejoin" + "github.com/dimfeld/httptreemux/v5" + "go.alanpearce.eu/elgit/config" + "go.alanpearce.eu/elgit/data" "go.alanpearce.eu/elgit/git" ) @@ -40,71 +45,73 @@ return false } -func (d *deps) isIgnored(name string) bool { - for _, i := range d.c.Repo.Ignore { - if name == i { - return true - } +func (d *deps) isNotAllowed(name string) bool { + return !slices.Contains(d.projects, name) +} + +func (d *deps) GetCleanPath(name string) (string, error) { + return securejoin.SecureJoin( + filepath.Join(d.c.Repo.Root, "repositories"), + httptreemux.Clean(name)+".git", + ) +} + +func ReadProjectsList(c *config.Config) ([]string, error) { + content, err := os.ReadFile(filepath.Join(c.Repo.Root, "projects.list")) + if err != nil { + return nil, err } + lines := bytes.SplitSeq(content, []byte("\n")) - return false -} + projects := []string{} + for line := range lines { + projects = append(projects, strings.TrimSuffix(string(line), ".git")) + } -type repoInfo struct { - Git *git.GitRepo - Path string - Category string + return projects, nil } -func (d *deps) getAllRepos() ([]repoInfo, error) { - repos := []repoInfo{} - max := strings.Count(d.c.Repo.ScanPath, string(os.PathSeparator)) + 2 +func (d *deps) getAllRepos() *data.Entries { + entries := &data.Entries{ + Children: []*data.Entry{}, + Map: map[string]*data.Entry{}, + } - err := filepath.WalkDir(d.c.Repo.ScanPath, func(path string, de fs.DirEntry, err error) error { - if err != nil { - return err + for _, project := range d.projects { + if project == "" || d.isUnlisted(project) { + continue } + fullPath := filepath.Join(d.c.Repo.Root, "repositories", project+".git") - if de.IsDir() { - // Check if we've exceeded our recursion depth - if strings.Count(path, string(os.PathSeparator)) > max { - return fs.SkipDir - } - - if d.isIgnored(path) { - return fs.SkipDir - } - - // A bare repo should always have at least a HEAD file, if it - // doesn't we can continue recursing - if _, err := os.Lstat(filepath.Join(path, "HEAD")); err == nil { - repo, err := git.Open(path, "") - if err != nil { - log.Println(err) - } else { - relpath, _ := filepath.Rel(d.c.Repo.ScanPath, path) - repos = append(repos, repoInfo{ - Git: repo, - Path: relpath, - Category: d.category(path), - }) - // Since we found a Git repo, we don't want to recurse - // further - return fs.SkipDir + // A bare repo should always have at least a HEAD file + if _, err := os.Lstat(filepath.Join(fullPath, "HEAD")); err == nil { + repo, err := git.Open(fullPath, "") + if err != nil { + log.Println(err) + } else { + category, name, found := strings.Cut(project, string(os.PathSeparator)) + if !found { + category = "" + name = project } + r := data.Repository{ + Name: name, + Category: category, + Path: fullPath, + Slug: project, + Description: getDescription(fullPath), + } + if cc, err := repo.LastCommit(); err == nil { + r.LastCommit = cc.Author.When + } + entries.Add(r) } } - return nil - }) + } - return repos, err -} + entries.Sort() -func (d *deps) category(path string) string { - return strings.TrimPrefix( - filepath.Dir(strings.TrimPrefix(path, d.c.Repo.ScanPath)), - string(os.PathSeparator), - ) + return entries } func setContentDisposition(w http.ResponseWriter, name string) {
M static/style.css → static/style.css
@@ -121,9 +121,26 @@ .index { padding-top: 2em; display: grid; - grid-template-columns: minmax(6em, 8em) 1fr minmax(0, 7em); + grid-template-columns: auto 1fr minmax(0, 7em); + grid-column-gap: 1ex; grid-row-gap: 0.5em; min-width: 0; +} + +.index-category { + grid-column: 1 / span 3; + display: grid; + grid-template-columns: auto 1fr minmax(0, 7em); +} + +.index-category > header { + color: var(--gray); + grid-column: 1 / span 3; + font-style: italic; +} + +.index-category-name { + margin-left: 1.5em; } .clone-url { @@ -324,6 +341,14 @@ } .index-name:not(:first-child) { padding-top: 1.5rem; + } + + .index-category { + margin-top: 1.5rem; + } + + .index-category-name { + padding-top: 0.7rem; } .commit-info:not(:last-child) {
M templates/index.go → templates/index.go
@@ -1,36 +1,51 @@ package templates import ( - "time" - + "github.com/dustin/go-humanize" + "go.alanpearce.eu/elgit/data" g "go.alanpearce.eu/gomponents" + c "go.alanpearce.eu/gomponents/components" . "go.alanpearce.eu/gomponents/html" ) -type RepoInfo struct { - DisplayName string - Name string - Desc string - Idle string - LastCommit time.Time -} - -func Index(data PageData, repos []RepoInfo) g.Node { - return Page(data, g.Group{ +func Index(pd PageData, entries *data.Entries) g.Node { + return Page(pd, g.Group{ Header( - H1(g.Text(data.Meta.Title)), - H2(g.Text(data.Meta.Description)), + H1(g.Text(pd.Meta.Title)), + H2(g.Text(pd.Meta.Description)), ), Main( Div(Class("index"), - g.Map(repos, func(repo RepoInfo) g.Node { + g.Map(entries.Children, func(entry *data.Entry) g.Node { + return g.If(len(entry.Repositories) == 1, + Repository(entry.Repositories[0], 0), + ) + }), + g.MapMap(entries.Map, func(category string, entry *data.Entry) g.Node { return g.Group{ - Div(Class("index-name"), A(Href("/"+repo.Name), g.Text(repo.DisplayName))), - Div(Class("desc"), g.Text(repo.Desc)), - Div(g.Text(repo.Idle)), + Div(Class("index-category"), + Header(g.Text(category)), + ), + g.Map(entry.Repositories, func(repo *data.Repository) g.Node { + return Repository(repo, 1) + }), } }), ), ), }) } + +func Repository(repo *data.Repository, level int) g.Node { + return g.Group{ + Div( + c.Classes{ + "index-name": level == 0, + "index-category-name": level >= 1, + }, + A(Href("/"+repo.Slug), g.Text(repo.Name)), + ), + Div(Class("desc"), g.Text(repo.Description)), + Div(g.Text(humanize.Time(repo.LastCommit))), + } +}
M templates/page.go → templates/page.go
@@ -1,6 +1,8 @@ package templates import ( + "strings" + "go.alanpearce.eu/elgit/config" "go.alanpearce.eu/elgit/git" g "go.alanpearce.eu/gomponents" @@ -71,12 +73,12 @@ func RenderNav(data PageData) g.Node { var items []g.Node if data.Name != "" { - items = append(items, Li(A(Href("/"+data.Name), g.Text("summary")))) - items = append(items, Li(A(Href("/"+data.Name+"/refs"), g.Text("refs")))) + items = append(items, Li(A(Href(joinPaths(data.Name)), g.Text("summary")))) + items = append(items, Li(A(Href(joinPaths(data.Name, "refs", "")), g.Text("refs")))) if data.Ref != "" { - items = append(items, Li(A(Href("/"+data.Name+"/tree/"+data.Ref+"/"), g.Text("tree")))) - items = append(items, Li(A(Href("/"+data.Name+"/log/"+data.Ref), g.Text("log")))) + items = append(items, Li(A(Href(joinPaths(data.Name, "tree", data.Ref, "")), g.Text("tree")))) + items = append(items, Li(A(Href(joinPaths(data.Name, "log", data.Ref)), g.Text("log")))) } } @@ -90,7 +92,8 @@ A( Href("/"), g.Text("all repos"), ), - g.Textf(" — %s", data.DisplayName), + g.Text(" — "), + g.Text(data.DisplayName), g.Text(" "), g.If(data.Ref != "", Span(Class("ref"), g.Textf("@ %s", data.Ref))), ), @@ -105,3 +108,7 @@ Head: RenderHead(data), Body: body, }) } + +func joinPaths(paths ...string) string { + return "/" + strings.Join(paths, "/") +}
M templates/tree.go → templates/tree.go
@@ -19,7 +19,7 @@ g.If(data.Parent != "", g.Group{ Div(), Div(), - Div(A(Href(fmt.Sprintf("/%s/tree/%s/%s", data.Name, data.Ref, dotdot)), g.Text(".."))), + Div(A(Href(joinPaths(data.Name, "tree", data.Ref, dotdot)), g.Text(".."))), }, ), g.Map(files, func(file git.NiceTree) g.Node { @@ -31,12 +31,12 @@ Div( g.If(data.Parent != "", A( Href( - fmt.Sprintf("/%s/tree/%s/%s/%s", data.Name, data.Ref, data.Parent, file.Name), + joinPaths(data.Name, "tree", data.Ref, data.Parent, file.Name), ), g.Text(file.Name+"/"), ), A( - Href(fmt.Sprintf("/%s/tree/%s/%s", data.Name, data.Ref, file.Name)), + Href(joinPaths(data.Name, "tree", data.Ref, file.Name)), g.Text(file.Name+"/"), ), ),