28 files changed,
1530 insertions(+),
153 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-01-21 01:49:14 +0200
Change ID:
wxolklpkwwzqrztquvmtmtsmzuktwqvt
Parent:
7a05bd0
jump to
M
config.yml
@@ -6,7 +6,6 @@ meta:
title: i like git description: hey kid, come get your free software host: git.olexsmir.xyz - templates_dir: /home/olex/code/mugit/templates repo: dir: /home/olex/mugit-test/@@ -19,8 +18,3 @@ - readme.txt
masters: - master - main - private: - # repo also can be marked as private by: - # - putting `muprivate` file in the root of repo - # - adding `[mugit]\n private = true` - - org
M
go.mod
@@ -4,7 +4,9 @@ go 1.25.3
require ( github.com/bluekeyes/go-gitdiff v0.8.1 + github.com/dustin/go-humanize v1.0.1 github.com/go-git/go-git/v5 v5.16.4 + github.com/yuin/goldmark v1.7.16 gopkg.in/yaml.v2 v2.4.0 )
M
go.sum
@@ -18,6 +18,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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=@@ -69,6 +71,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
M
internal/config/config.go
@@ -14,21 +14,15 @@ Host string `yaml:"host"`
Port int `yaml:"port"` } `yaml:"server"` Meta struct { - Title string `yaml:"title"` - Description string `yaml:"description"` - Host string `yaml:"host"` - ChromaTheme string `yaml:"chroma_theme"` - TemplatesDir string `yaml:"templates_dir"` + Title string `yaml:"title"` + Description string `yaml:"description"` + Host string `yaml:"host"` } `yaml:"meta"` Repo struct { Dir string `yaml:"dir"` Readmes []string `yaml:"readmes"` Masters []string `yaml:"masters"` - Private []string `yaml:"private"` } `yaml:"repo"` - SSH struct { - Keys []string `yaml:"keys"` - } `yaml:"ssh"` } func Load(fpath string) (*Config, error) {@@ -45,12 +39,6 @@
if config.Repo.Dir, err = filepath.Abs(config.Repo.Dir); err != nil { return nil, err } - - if config.Meta.TemplatesDir, err = filepath.Abs(config.Meta.TemplatesDir); err != nil { - return nil, err - } - - fmt.Println(config.Meta.TemplatesDir) if verr := config.validate(); verr != nil { return nil, verr
M
internal/git/repo.go
@@ -44,7 +44,10 @@ return &g, nil
} func (g *Repo) Commits() ([]*object.Commit, error) { - ci, err := g.r.Log(&git.LogOptions{From: g.h}) + ci, err := g.r.Log(&git.LogOptions{ + From: g.h, + Order: git.LogOrderCommitterTime, + }) if err != nil { return nil, fmt.Errorf("commits from ref: %w", err) }
A
internal/handlers/handlers.go
@@ -0,0 +1,79 @@
+package handlers + +import ( + "fmt" + "html/template" + "net/http" + "path/filepath" + "strings" + "time" + + "olexsmir.xyz/mugit/internal/config" + "olexsmir.xyz/mugit/internal/humanize" + "olexsmir.xyz/mugit/web" +) + +type handlers struct { + c *config.Config + t *template.Template +} + +func InitRoutes(cfg *config.Config) *http.ServeMux { + tmpls := template.Must(template.New(""). + Funcs(templateFuncs). + ParseFS(web.TemplatesFS, "*")) + h := handlers{cfg, tmpls} + + mux := http.NewServeMux() + mux.HandleFunc("GET /", h.index) + mux.HandleFunc("GET /static/{file}", h.serveStatic) + mux.HandleFunc("GET /{name}", h.multiplex) + mux.HandleFunc("POST /{name}", h.multiplex) + mux.HandleFunc("GET /{name}/{rest...}", h.multiplex) + mux.HandleFunc("POST /{name}/{rest...}", h.multiplex) + mux.HandleFunc("GET /{name}/tree/{ref}/{rest...}", h.repoTree) + mux.HandleFunc("GET /{name}/blob/{ref}/{rest...}", h.fileContents) + mux.HandleFunc("GET /{name}/log/{ref}", h.log) + mux.HandleFunc("GET /{name}/commit/{ref}", h.commit) + mux.HandleFunc("GET /{name}/refs/{$}", h.refs) + return mux +} + +// multiplex, check if the request smells like gitprotocol-http(5), if so, it +// passes it to git smart http, otherwise renders templates +func (h *handlers) multiplex(w http.ResponseWriter, r *http.Request) { + if r.URL.RawQuery == "service=git-receive-pack" { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("http pushing isn't supported")) + return + } + + path := r.PathValue("rest") + if path == "info/refs" && r.Method == "GET" && r.URL.RawQuery == "service=git-upload-pack" { + h.infoRefs(w, r) + } else if path == "git-upload-pack" && r.Method == "POST" { + h.uploadPack(w, r) + } else if r.Method == "GET" { + h.repoIndex(w, r) + } +} + +func (h *handlers) serveStatic(w http.ResponseWriter, r *http.Request) { + f := filepath.Clean(r.PathValue("file")) + // TODO: check if files exists + http.ServeFileFS(w, r, web.StaticFS, f) +} + +var templateFuncs = template.FuncMap{ + "commitSummary": func(v any) string { + s := fmt.Sprint(v) + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = strings.TrimSuffix(s[:i], "\r") + return s + "..." + } + return strings.TrimSuffix(s, "\r") + }, + "humanTime": func(t time.Time) string { + return humanize.Time(t) + }, +}
D
@@ -1,47 +0,0 @@
-package handlers - -import ( - "html/template" - "net/http" - "path/filepath" - - "olexsmir.xyz/mugit/internal/config" -) - -type handlers struct { - c *config.Config - t *template.Template -} - -func InitRoutes(cfg *config.Config) *http.ServeMux { - tmpls := template.Must(template.ParseGlob( - filepath.Join(cfg.Meta.TemplatesDir, "*"), - )) - h := handlers{cfg, tmpls} - - mux := http.NewServeMux() - mux.HandleFunc("GET /", h.index) - - return mux -} - -// multiplex if request smells like gitprotocol-http(5) passes it to the git -// http service renders templates. -func (h *handlers) multiplex(w http.ResponseWriter, r *http.Request) { - if r.URL.RawQuery == "service=git-receive-pack" { - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("http pushing isn't supported")) - return - } - - path := r.PathValue("rest") - if path == "info/refs" && - r.URL.RawQuery == "service=git-upload-pack" && - r.Method == "GET" { - h.infoRefs(w, r) - } else if path == "git-upload-pack" && r.Method == "POST" { - h.uploadPack(w, r) - } else if r.Method == "GET" { - h.repoIndex(w, r) - } -}
A
internal/handlers/repo.go
@@ -0,0 +1,399 @@
+package handlers + +import ( + "bytes" + "fmt" + "html/template" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/dustin/go-humanize" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer/html" + "olexsmir.xyz/mugit/internal/git" +) + +func (h *handlers) index(w http.ResponseWriter, r *http.Request) { + dirs, err := os.ReadDir(h.c.Repo.Dir) + if err != nil { + h.write500(w, err) + return + } + + type repoInfo struct { + Name, Desc, Idle string + t time.Time + } + + repoInfos := []repoInfo{} + for _, dir := range dirs { + name := dir.Name() + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), "") + if err != nil { + slog.Error("", "name", name, "err", err) + continue + } + + desc, err := repo.Description() + if err != nil { + slog.Error("", "err", err) + continue + } + + lastComit, err := repo.LastCommit() + if err != nil { + slog.Error("", "err", err) + continue + } + + repoInfos = append(repoInfos, repoInfo{ + Name: name, + Desc: desc, + Idle: humanize.Time(lastComit.Author.When), + t: lastComit.Author.When, + }) + } + + sort.Slice(repoInfos, func(i, j int) bool { + return repoInfos[j].t.Before(repoInfos[i].t) + }) + + data := make(map[string]any) + data["meta"] = h.c.Meta + data["repos"] = repoInfos + h.templ(w, "index", data) +} + +var markdown = goldmark.New( + goldmark.WithRendererOptions(html.WithUnsafe()), + goldmark.WithExtensions( + extension.GFM, + extension.Linkify, + )) + +func (h *handlers) repoIndex(w http.ResponseWriter, r *http.Request) { + name := filepath.Clean(r.PathValue("name")) + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), "") + if err != nil { + h.write404(w, err) + return + } + + isPrivate, err := repo.IsPrivate() + if isPrivate || err != nil { // FIX: private = 404, err = 500 + h.write404(w, err) + return + } + + var readmeContents template.HTML + for _, readme := range h.c.Repo.Readmes { + ext := filepath.Ext(readme) + content, _ := repo.FileContent(readme) + if len(content) > 0 { + switch ext { + case ".md", ".markdown", ".mkd": + var buf bytes.Buffer + if cerr := markdown.Convert([]byte(content), &buf); cerr != nil { + h.write500(w, cerr) + return + } + readmeContents = template.HTML(buf.String()) + default: + readmeContents = template.HTML(fmt.Sprintf(`<pre>%s</pre>`, content)) + } + break + } + } + + masterBranch, err := repo.FindMasterBranch(h.c.Repo.Masters) + if err != nil { + h.write500(w, err) + return + } + + desc, err := repo.Description() + if err != nil { + h.write500(w, err) + return + } + + commits, err := repo.Commits() + if err != nil { + h.write500(w, err) + return + } + + if len(commits) >= 4 { + commits = commits[:3] + } + + data := make(map[string]any) + data["name"] = name + data["ref"] = masterBranch + data["desc"] = desc + data["readme"] = readmeContents + data["commits"] = commits + data["servername"] = h.c.Meta.Host + data["meta"] = h.c.Meta + data["gomod"] = repo.IsGoMod() + + h.templ(w, "repo_index", data) +} + +func (h *handlers) repoTree(w http.ResponseWriter, r *http.Request) { + name := filepath.Clean(r.PathValue("name")) + ref := r.PathValue("ref") + treePath := r.PathValue("rest") + + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), ref) + if err != nil { + h.write404(w, err) + return + } + + isPrivate, err := repo.IsPrivate() + if isPrivate || err != nil { + h.write404(w, err) + return + } + + desc, err := repo.Description() + if err != nil { + h.write500(w, err) + return + } + + files, err := repo.FileTree(treePath) + if err != nil { + h.write500(w, err) + return + } + + data := make(map[string]any) + data["name"] = name + data["ref"] = ref + data["parent"] = treePath + data["dotdot"] = filepath.Dir(treePath) + data["desc"] = desc + data["meta"] = h.c.Meta + data["files"] = files + + h.templ(w, "repo_tree", data) +} + +func (h *handlers) fileContents(w http.ResponseWriter, r *http.Request) { + name := filepath.Clean(r.PathValue("name")) + ref := r.PathValue("ref") + treePath := r.PathValue("rest") + + var raw bool + if rawParam, err := strconv.ParseBool(r.URL.Query().Get("raw")); err == nil { + raw = rawParam + } + + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), "") + if err != nil { + h.write404(w, err) + return + } + + isPrivate, err := repo.IsPrivate() + if isPrivate || err != nil { + h.write404(w, err) + return + } + + desc, err := repo.Description() + if err != nil { + h.write500(w, err) + return + } + + contents, err := repo.FileContent(treePath) + if err != nil { + h.write500(w, err) + return + } + + data := make(map[string]any) + data["name"] = name + data["ref"] = ref + data["desc"] = desc + data["path"] = treePath + + if raw { + w.WriteHeader(http.StatusOK) + w.Header().Set("Content-Type", "text/plain") + w.Write([]byte(contents)) + return + } + + lc, err := countLines(strings.NewReader(contents)) + if err != nil { + slog.Error("failed to count line numbers", "err", err) + } + + lines := make([]int, lc) + if lc > 0 { + for i := range lines { + lines[i] = i + 1 + } + } + + data["linecount"] = lines + data["content"] = contents + data["meta"] = h.c.Meta + + h.templ(w, "file", data) +} + +func (h *handlers) log(w http.ResponseWriter, r *http.Request) { + name := filepath.Clean(r.PathValue("name")) + ref := r.PathValue("ref") + + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), ref) + if err != nil { + h.write404(w, err) + return + } + + isPrivate, err := repo.IsPrivate() + if isPrivate || err != nil { + h.write404(w, err) + return + } + + commits, err := repo.Commits() + if err != nil { + h.write500(w, err) + return + } + + desc, err := repo.Description() + if err != nil { + h.write500(w, err) + return + } + + data := make(map[string]any) + data["name"] = name + data["ref"] = ref + data["desc"] = desc + data["meta"] = h.c.Meta + data["log"] = true + data["commits"] = commits + h.templ(w, "repo_log", data) +} + +func (h *handlers) commit(w http.ResponseWriter, r *http.Request) { + name := filepath.Clean(r.PathValue("name")) + ref := r.PathValue("ref") + + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), ref) + if err != nil { + h.write404(w, err) + return + } + + isPrivate, err := repo.IsPrivate() + if isPrivate || err != nil { + h.write404(w, err) + return + } + + diff, err := repo.Diff() + if err != nil { + h.write500(w, err) + return + } + + desc, err := repo.Description() + if err != nil { + h.write500(w, err) + return + } + + data := make(map[string]any) + data["stat"] = diff.Stat + data["diff"] = diff.Diff + data["commit"] = diff.Commit + data["name"] = name + data["ref"] = ref + data["desc"] = desc + h.templ(w, "commit", data) +} + +func (h *handlers) refs(w http.ResponseWriter, r *http.Request) { + name := filepath.Clean(r.PathValue("name")) + repo, err := git.Open(filepath.Join(h.c.Repo.Dir, name), "") + if err != nil { + h.write404(w, err) + return + } + + isPrivate, err := repo.IsPrivate() + if isPrivate || err != nil { + h.write404(w, err) + return + } + + desc, err := repo.Description() + if err != nil { + h.write500(w, err) + return + } + + branches, err := repo.Branches() + if err != nil { + h.write500(w, err) + return + } + + tags, err := repo.Tags() + if err != nil { + // repo should have at least one branch, tags are *optional* + slog.Error("couldn't fetch repo tags", "err", err) + } + + data := make(map[string]any) + data["meta"] = h.c.Meta + data["name"] = name + data["desc"] = desc + data["branches"] = branches + data["tags"] = tags + h.templ(w, "repo_refs", data) +} + +func countLines(r io.Reader) (int, error) { + buf := make([]byte, 32*1024) + bufLen := 0 + count := 0 + nl := []byte{'\n'} + + for { + c, err := r.Read(buf) + if c > 0 { + bufLen += c + } + count += bytes.Count(buf[:c], nl) + + switch { + case err == io.EOF: + // handle last line not having a newline at the end + if bufLen >= 1 && buf[(bufLen-1)%(32*1024)] != '\n' { + count++ + } + return count, nil + case err != nil: + return 0, err + } + } +}
D
@@ -1,16 +0,0 @@
-package handlers - -import ( - "log/slog" - "net/http" -) - -func (h *handlers) index(w http.ResponseWriter, r *http.Request) { - data := make(map[string]any) - data["meta"] = h.c.Meta - - w.WriteHeader(http.StatusOK) - if err := h.t.ExecuteTemplate(w, "index", nil); err != nil { - slog.Error("index template", "err", err) - } -}
M
internal/handlers/util.go
@@ -5,16 +5,21 @@ "log/slog"
"net/http" ) -func (h *handlers) write404(w http.ResponseWriter) { +func (h *handlers) templ(w http.ResponseWriter, name string, data any) { + if err := h.t.ExecuteTemplate(w, name, data); err != nil { + w.WriteHeader(http.StatusInternalServerError) + slog.Error("template", "name", name, "err", err) + } +} + +func (h *handlers) write404(w http.ResponseWriter, err error) { + slog.Info("404", "err", err) w.WriteHeader(http.StatusNotFound) - if err := h.t.ExecuteTemplate(w, "404", nil); err != nil { - slog.Error("404 template", "err", err) - } + h.templ(w, "404", nil) } -func (h *handlers) write500(w http.ResponseWriter) { +func (h *handlers) write500(w http.ResponseWriter, err error) { + slog.Info("500", "err", err) w.WriteHeader(http.StatusInternalServerError) - if err := h.t.ExecuteTemplate(w, "500", nil); err != nil { - slog.Error("500 template", "err", err) - } + h.templ(w, "500", nil) }
M
main.go
@@ -29,8 +29,8 @@
mux := handlers.InitRoutes(cfg) port := strconv.Itoa(cfg.Server.Port) - err = http.ListenAndServe(net.JoinHostPort(cfg.Server.Host, port), mux) - if err != nil { + slog.Info("starting server", "host", cfg.Server.Host, "port", port) + if err = http.ListenAndServe(net.JoinHostPort(cfg.Server.Host, port), mux); err != nil { slog.Error("server error", "err", err) }
M
web/templates/500.html
→web/templates/500.html
@@ -1,9 +1,11 @@
{{ define "500" }} <html> - <title>500</title> + <head> + <title>500</title> {{ template "head" . }} + </head> <body> - {{ template "nav" . }} + <!-- {{ template "nav" . }} --> <main> <h3>500 — something broke!</h3> </main>
D
@@ -1,31 +0,0 @@
-{{ define "head" }} - <head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1"> - <link rel="stylesheet" href="/static/style.css" type="text/css"> - <!-- TODO: icon --> - {{ if .parent }} - <title>{{ .meta.Title }} — {{ .name }} ({{ .ref }}): {{ .parent }}/</title> - - {{ else if .path }} - <title>{{ .meta.Title }} — {{ .name }} ({{ .ref }}): {{ .path }}</title> - {{ else if .files }} - <title>{{ .meta.Title }} — {{ .name }} ({{ .ref }})</title> - {{ else if .commit }} - <title>{{ .meta.Title }} — {{ .name }}: {{ .commit.This }}</title> - {{ else if .branches }} - <title>{{ .meta.Title }} — {{ .name }}: refs</title> - {{ else if .commits }} - {{ if .log }} - <title>{{ .meta.Title }} — {{ .name }}: log</title> - {{ else }} - <title>{{ .meta.Title }} — {{ .name }}</title> - {{ end }} - {{ else }} - <title>{{ .meta.Title }}</title> - {{ end }} - <!-- {{ if and .servername .gomod }} --> - <!-- <meta name="go-import" content="{{ .servername}}/{{ .name }} git https://{{ .servername }}/{{ .name }}"> --> - <!-- {{ end }} --> - </head> -{{ end }}
A
web/static/style.css
@@ -0,0 +1,552 @@
+:root { + --white: #fff; + --light: #f4f4f4; + --cyan: #509c93; + --light-gray: #eee; + --medium-gray: #ddd; + --gray: #6a6a6a; + --dark: #444; + --darker: #222; + + --sans-font: -apple-system, BlinkMacSystemFont, "Inter", "Roboto", "Segoe UI", sans-serif; + --display-font: -apple-system, BlinkMacSystemFont, "Inter", "Roboto", "Segoe UI", sans-serif; + --mono-font: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', 'Roboto Mono', Menlo, Consolas, monospace; +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark light; + --light: #181818; + --cyan: #76c7c0; + --light-gray: #333; + --medium-gray: #444; + --gray: #aaa; + --dark: #ddd; + --darker: #f4f4f4; + --white: #000; + } +} + +html { + background: var(--white); + -webkit-text-size-adjust: none; + font-family: var(--sans-font); + font-weight: 380; +} + +pre { + font-family: var(--mono-font); + overflow-x: auto; +} + +::selection { + background: var(--medium-gray); + opacity: 0.3; +} + +* { + box-sizing: border-box; + padding: 0; + margin: 0; +} + +body { + max-width: 1200px; + padding: 0 13px; + margin: 40px auto; +} + +main, footer { + font-size: 1rem; + padding: 0; + line-height: 160%; +} + +header h1, h2, h3 { + font-family: var(--display-font); +} + +h2 { font-weight: 400; } +strong { font-weight: 500; } + +main h1 { padding: 10px 0 10px 0; } +main h2 { font-size: 18px; } +main h3 { font-size: 1.15rem; } +main h2, h3 { padding: 20px 0 0.4rem 0; } + +nav { padding: 0.4rem 0 1.5rem 0; } +nav ul { + padding: 0; + margin: 0; + list-style: none; + padding-bottom: 20px; +} + +nav ul li { + padding-right: 10px; + display: inline-block; +} + +.repo-header { + margin-bottom: 1.25rem; +} + +.repo-breadcrumb { + color: var(--gray); +} + +.repo-breadcrumb a { + color: var(--gray); +} + +.repo-name { + font-size: 1.6rem; + line-height: 1.1; + margin-top: 0.25rem; +} + +.repo-header .desc { + color: var(--gray); + margin-top: 0.35rem; +} + +.repo-header .repo-nav { + padding: 0.5rem 0 0 0; +} + +.repo-header .repo-nav ul { + padding-bottom: 0; +} + +a { + margin: 0; + padding: 0; + box-sizing: border-box; + text-decoration: none; + word-wrap: break-word; + color: var(--darker); + border-bottom: 0; +} + +a:hover { + border-bottom: 1.5px solid var(--gray); +} + +/* index page */ +.index { + width: 100%; + margin-top: 2em; + border-collapse: collapse; + table-layout: auto; +} + +.index th { + text-align: left; + font-weight: 500; + border-bottom: 1.5px solid var(--medium-gray); + padding: 0.25em 0.5em; +} + +.index td { + border-bottom: 1px solid var(--light-gray); + padding: 0.25em 0.5em; + vertical-align: top; +} + +.index .url { white-space: nowrap; } +.index .desc { width: 100%; } +.index .idle { white-space: nowrap; } + +.index tbody tr.nohover:hover { + background: transparent; +} + +.index tbody tr:hover { + background: var(--light); +} + +/* tree page */ + +.tree { + width: 100%; + margin-bottom: 2em; + border-collapse: collapse; + table-layout: auto; +} + +.tree th { + text-align: left; + font-weight: 500; + border-bottom: 1.5px solid var(--medium-gray); + padding: 0.25em 0.5em; +} + +.tree td { + border-bottom: 1px solid var(--light-gray); + padding: 0.25em 0.5em; + vertical-align: top; +} + +.tree .mode { + white-space: nowrap; + font-family: var(--mono-font); +} + +.tree .size { + white-space: nowrap; + text-align: right; + font-family: var(--mono-font); +} + +.tree .name { width: 100%; } + +.tree tbody tr.nohover:hover { + background: transparent; +} + +.tree tbody tr:hover { + background: var(--light); +} + +/* log/repo page */ + +.repo-index { + display: flex; + gap: 1.25rem; + margin-bottom: 2em; + align-items: flex-start; +} + +.repo-index-main { + flex: 0 0 72ch; +} + +.repo-index-side { + flex: 0 0 26rem; + min-width: 0; +} + +.repo-index-main .box { + width: 100%; +} + +.box { + background: var(--light-gray); + padding: 0.6rem; +} + +.box + .box { + margin-top: 0.8rem; +} + +.log { + width: 100%; + margin-bottom: 2em; + border-collapse: collapse; + table-layout: auto; +} + +.log th { + text-align: left; + font-weight: 500; + border-bottom: 1.5px solid var(--medium-gray); + padding: 0.25em 0.5em; +} + +.log td { + border-bottom: 1px solid var(--light-gray); + padding: 0.25em 0.5em; + vertical-align: top; +} + +.log .msg { width: 100%; } +.log .author { white-space: nowrap; position: relative; } +.log .age { white-space: nowrap; } + +.log td.author .author-short { + display: inline-block; + max-width: 25ch; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: top; +} + +.log td.author .author-tip { + display: none; + position: absolute; + left: 0; + top: 100%; + margin-top: 0.25rem; + padding: 0.35rem 0.5rem; + background: var(--white); + border: 1px solid var(--medium-gray); + max-width: 48ch; + z-index: 2; +} + +.log td.author:hover .author-tip, +.log td.author:focus-within .author-tip { + display: block; +} + +.log tbody tr.nohover:hover { + background: transparent; +} + +.log tbody tr:hover { + background: var(--light); +} + +.clone-url pre { + overflow-x: auto; + white-space: pre; + max-width: 100%; +} + +.mode, .size { + font-family: var(--mono-font); +} +.size { + text-align: right; +} + +/* readme stuff */ + +.readme pre { + white-space: pre-wrap; + overflow-x: auto; +} + +.readme { + background: var(--light-gray); + padding: 0.5rem; +} + +.readme ul { + padding: revert; +} + +.readme img { + max-width: 100%; +} + +.diff { + margin: 1rem 0 1rem 0; + padding: 0.6rem; + border-bottom: 1.5px solid var(--medium-gray); + background: var(--light-gray); +} + +.diff pre { + overflow-x: auto; +} + +.commit-refs { + border-collapse: collapse; + margin: 0.5rem 0 1rem 0; +} + +.commit-refs td { + padding: 0.15rem 0.5rem 0.15rem 0; + vertical-align: top; +} + +.commit-refs td.label { + white-space: nowrap; + padding-right: 1rem; +} + +.diff-stat { + padding: 1rem 0 1rem 0; +} + +.jump { + margin-top: 0.5rem; +} + +.jump-table { + width: 100%; + border-collapse: collapse; + table-layout: auto; + margin-top: 0.25rem; +} + +.jump-table td { + padding: 0.15rem 0.5rem; + border-bottom: 1px solid var(--medium-gray); + vertical-align: top; +} + +.jump-table .diff-type { + font-family: var(--mono-font); + white-space: nowrap; + width: 2ch; +} + +.jump-table .path { + width: 100%; +} + +.commit-hash, .commit-email { + font-family: var(--mono-font); +} + +.commit-email:before { + content: '<'; +} + +.commit-email:after { + content: '>'; +} + +.commit { + margin-bottom: 1rem; +} + + .commit pre { + padding-bottom: 0; + white-space: pre-wrap; + } + + .commit-message { + margin-top: 0.25rem; + font-size: 1rem; + line-height: 1.35; + margin-bottom: 0; + } + + + .commit .box { + margin-bottom: 0.25rem; + } + + + .commit .commit-info { + padding-bottom: 0.25rem; + } + + + + +.diff-add { + color: green; +} + +.diff-del { + color: red; +} + +.diff-noop { + color: var(--gray); +} + +.ref { + font-family: var(--sans-font); + font-size: 14px; + color: var(--gray); + display: inline-block; + padding-top: 0.7em; +} + +.refs pre { + white-space: pre-wrap; + padding-bottom: 0.5rem; +} + +.refs strong { + padding-right: 1em; +} + +.line-numbers { + white-space: pre-line; + -moz-user-select: -moz-none; + -khtml-user-select: none; + -webkit-user-select: none; + -o-user-select: none; + user-select: none; + display: flex; + float: left; + flex-direction: column; + margin-right: 1ch; +} + +.file-wrapper { + display: flex; + flex-direction: row; + grid-template-columns: 1rem minmax(0, 1fr); + gap: 1rem; + padding: 0.5rem; + background: var(--light-gray); + overflow-x: auto; +} + +.chroma-file-wrapper { + display: flex; + flex-direction: row; + grid-template-columns: 1rem minmax(0, 1fr); + overflow-x: auto; +} + +.file-content { + background: var(--light-gray); + overflow-y: hidden; + overflow-x: auto; +} + +.diff-type { + font-family: var(--mono-font); +} + +.diff-type.diff-add { color: green; } +.diff-type.diff-del { color: red; } +.diff-type.diff-mod { color: var(--cyan); } + +.commit-info { + color: var(--gray); + font-size: 0.85rem; +} + +.commit-date { + float: right; +} + +@media (max-width: 600px) { + .index { + grid-row-gap: 0.8em; + } + + .repo-index { + flex-direction: column; + } + + .repo-index-main { + flex: none; + } + + .repo-index-side { + flex: none; + } + + .log { + grid-template-columns: 1fr; + grid-row-gap: 0em; + } + + .index { + grid-template-columns: 1fr; + grid-row-gap: 0em; + } + + .index-name:not(:first-child) { + padding-top: 1.5rem; + } + + .commit-info:not(:last-child) { + padding-bottom: 1.5rem; + } + + pre { + font-size: 0.8rem; + } +}
A
web/templates/404.html
@@ -0,0 +1,14 @@
+{{ define "404" }} +<html> + <head> + <title>404</title> +{{ template "head" . }} + </head> + <body> + <!-- {{ template "nav" . }} --> + <main> + <h3>404 — nothing like that here.</h3> + </main> + </body> +</html> +{{ end }}
A
web/templates/_head.html
@@ -0,0 +1,9 @@
+{{ define "head" }} + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <link rel="stylesheet" href="/static/style.css" type="text/css"> + <!-- TODO: icon --> + {{ if and .servername .gomod }} + <meta name="go-import" content="{{ .servername}}/{{ .name }} git https://{{ .servername }}/{{ .name }}"> + {{ end }} +{{ end }}
A
web/templates/_repo_header.html
@@ -0,0 +1,28 @@
+{{ define "repo_header" }} +<header class="repo-header"> + <div class="repo-breadcrumb"> + <a href="/">all repos</a> + {{- if .ref }} + <span class="ref">@ {{ .ref }}</span> + {{- end }} + </div> + + <h1 class="repo-name">{{ .name }}</h1> + {{- if .desc }} + <div class="desc">{{ .desc }}</div> + {{- end }} + + <nav class="repo-nav"> + <ul> + {{- if .name }} + <li><a href="/{{ .name }}">summary</a></li> + <li><a href="/{{ .name }}/refs">refs</a></li> + {{- if .ref }} + <li><a href="/{{ .name }}/tree/{{ .ref }}/">tree</a></li> + <li><a href="/{{ .name }}/log/{{ .ref }}">log</a></li> + {{- end }} + {{- end }} + </ul> + </nav> +</header> +{{ end }}
A
web/templates/file.html
@@ -0,0 +1,37 @@
+{{ define "file" }} +<html> + <head> + {{ template "head" . }} + </head> + {{ template "repo_header" . }} + <body> + <main> + <p>{{ .path }} (<a style="color: gray" href="?raw=true">view raw</a>)</p> + {{if .chroma }} + <div class="chroma-file-wrapper"> + {{ .content }} + </div> + {{else}} + <div class="file-wrapper"> + <table> + <tbody><tr> + <td class="line-numbers"> + <pre> + {{- range .linecount }} + <a id="L{{ . }}" href="#L{{ . }}">{{ . }}</a> + {{- end -}} + </pre> + </td> + <td class="file-content"> + <pre> + {{- .content -}} + </pre> + </td> + </tbody></tr> + </table> + </div> + {{end}} + </main> + </body> +</html> +{{ end }}
A
web/templates/index.html
@@ -0,0 +1,35 @@
+{{ define "index" }} +<!DOCTYPE html> +<html> + <head> + {{ template "head" . }} + <title>{{ .meta.Title }}</title> + </head> + <header> + <h1>{{ .meta.Title }}</h1> + <h2>{{ .meta.Description }}</h2> + </header> + <body> + <main> + <table class="index"> + <thead> + <tr class="nohover"> + <th class="url">Name</th> + <th class="desc">Description</th> + <th class="idle">Idle</th> + </tr> + </thead> + <tbody> + {{ range .repos }} + <tr> + <td class="url"><a href="/{{ .Name }}">{{ .Name }}</a></td> + <td class="desc">{{ .Desc }}</td> + <td class="idle">{{ .Idle }}</td> + </tr> + {{ end}} + </tbody> + </table> + </main> + </body> +</html> +{{ end }}
A
web/templates/repo_commit.html
@@ -0,0 +1,120 @@
+{{ define "commit" }} +<html> + <head> + {{ template "head" . }} + <title>{{ .name }}: {{ .commit.This }}</title> + </head> + {{ template "repo_header" . }} + <body> + <main> + <section class="commit"> + <div class="box"> + <div class="commit-info"> + <span class="commit-date">{{ .commit.Author.When.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}</span> + {{ .commit.Author.Name }} <a href="mailto:{{ .commit.Author.Email }}" class="commit-email">{{ .commit.Author.Email }}</a> + </div> + <pre class="commit-message">{{- .commit.Message -}}</pre> + </div> + + <table class="commit-refs"> + <tbody> + <tr> + <td class="label"><strong>commit</strong></td> + <td> + <span class="commit-hash">{{ .commit.This }}</span> + </td> + </tr> + {{ if .commit.Parent }} + <tr> + <td class="label"><strong>parent</strong></td> + <td> + <span class="commit-hash">{{ .commit.Parent }}</span> + </td> + </tr> + {{ end }} + </tbody> + </table> + + <div class="diff-stat"> + <div> + {{ .stat.FilesChanged }} files changed, + {{ .stat.Insertions }} insertions(+), + {{ .stat.Deletions }} deletions(-) + </div> + + <div class="jump"> + <strong>jump to</strong> + <table class="jump-table"> + <tbody> + {{ range .diff }} + {{ $path := .Name.New }} + {{ if not $path }}{{ $path = .Name.Old }}{{ end }} + <tr> + <td class="diff-type"> + {{ if .IsNew }}<span class="diff-type diff-add">A</span>{{ end }} + {{ if .IsDelete }}<span class="diff-type diff-del">D</span>{{ end }} + {{ if not (or .IsNew .IsDelete) }}<span class="diff-type diff-mod">M</span>{{ end }} + </td> + <td class="path"><a href="#{{ $path }}">{{ $path }}</a></td> + </tr> + {{ end }} + </tbody> + </table> + </div> + </div> + </section> + <section> + {{ $repo := .name }} + {{ $this := .commit.This }} + {{ $parent := .commit.Parent }} + {{ range .diff }} + {{ $path := .Name.New }} + {{ if not $path }}{{ $path = .Name.Old }}{{ end }} + <div id="{{ $path }}"> + <div class="diff"> + {{ if .IsNew }} + <span class="diff-type diff-add">A</span> + {{ end }} + {{ if .IsDelete }} + <span class="diff-type diff-del">D</span> + {{ end }} + {{ if not (or .IsNew .IsDelete) }} + <span class="diff-type diff-mod">M</span> + {{ end }} + {{ if .Name.Old }} + <a href="/{{ $repo }}/blob/{{ $parent }}/{{ .Name.Old }}">{{ .Name.Old }}</a> + {{ if .Name.New }} + → + <a href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}">{{ .Name.New }}</a> + {{ end }} + {{ else }} + <a href="/{{ $repo }}/blob/{{ $this }}/{{ .Name.New }}">{{ .Name.New }}</a> + {{- end -}} + {{ if .IsBinary }} + <p>Not showing binary file.</p> + {{ else }} + <pre> + {{- range .TextFragments -}} + <p>{{- .Header -}}</p> + {{- range .Lines -}} + {{- if eq .Op.String "+" -}} + <span class="diff-add">{{ .String }}</span> + {{- end -}} + {{- if eq .Op.String "-" -}} + <span class="diff-del">{{ .String }}</span> + {{- end -}} + {{- if eq .Op.String " " -}} + <span class="diff-noop">{{ .String }}</span> + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + </pre> + </div> + </div> + {{ end }} + </section> + </main> + </body> +</html> +{{ end }}
A
web/templates/repo_index.html
@@ -0,0 +1,47 @@
+{{ define "repo_index" }} +<!DOCTYPE html> +<html> + <head> + {{ template "head" . }} + <title>{{ .name }} — {{ .meta.Title }}</title> + </head> + <body> + {{ template "repo_header" . }} + + <main> + {{ $repo := .name }} + + <section class="repo-index"> + <div class="repo-index-main"> + {{ range .commits }} + <div class="box"> + <div> + <a href="/{{ $repo }}/commit/{{ .Hash.String }}" class="commit-hash">{{ slice .Hash.String 0 8 }}</a> + — {{ .Author.Name }} + <span class="commit-date commit-info">{{ .Author.When.Format "Mon, 02 Jan 2006" }}</span> + </div> + <div>{{ commitSummary .Message }}</div> + </div> + {{ end }} + </div> + + <aside class="repo-index-side"> + <div class="box"> + <strong>clone</strong> + <pre>{{- /**/ -}} +https://{{ .servername }}/{{ .name }} +git@{{ .servername }}:{{ .name }} +{{- /**/ -}}</pre> + </div> + </aside> + </section> + + {{- if .readme }} + <article class="readme"> + {{- .readme -}} + </article> + {{- end -}} + </main> + </body> +</html> +{{ end }}
A
web/templates/repo_log.html
@@ -0,0 +1,43 @@
+{{ define "repo_log" }} +<html> + <head> + {{ template "head" . }} + <title>{{ .name }}: log</title> + </head> + {{ template "repo_header" . }} + <body> + <main> + {{ $repo := .name }} + + <table class="log"> + <thead> + <tr class="nohover"> + <th class="msg">commit</th> + <th class="author">author</th> + <th class="age">age</th> + </tr> + </thead> + <tbody> + {{ range .commits }} + <tr> + <td class="msg"> + <a href="/{{ $repo }}/commit/{{ .Hash.String }}">{{ commitSummary .Message }}</a> + </td> + <td class="author"> + <span class="author-short"> + {{ .Author.Name }} <a href="mailto:{{ .Author.Email }}" class="commit-email">{{ .Author.Email }}</a> + </span> + <span class="author-tip" role="tooltip"> + <strong>{{ .Author.Name }}</strong><br> + <a href="mailto:{{ .Author.Email }}" class="commit-email">{{ .Author.Email }}</a> + </span> + </td> + <td class="age">{{ humanTime .Committer.When }}</td> + </tr> + {{ end }} + </tbody> + </table> + </main> + </body> +</html> +{{ end }}
A
web/templates/repo_refs.html
@@ -0,0 +1,40 @@
+{{ define "repo_refs" }} +<html> + <head> + {{ template "head" . }} + <title>{{ .name }}: refs</title> + </head> + {{ template "repo_header" . }} + <body> + <main> + {{ $name := .name }} + <h3>branches</h3> + <div class="refs"> + {{ range .branches }} + <div> + <strong>{{ .Name.Short }}</strong> + <a href="/{{ $name }}/tree/{{ .Name.Short }}/">browse</a> + <a href="/{{ $name }}/log/{{ .Name.Short }}">log</a> + <a href="/{{ $name }}/archive/{{ .Name.Short }}.tar.gz">tar.gz</a> + </div> + {{ end }} + </div> + {{ if .tags }} + <h3>tags</h3> + <div class="refs"> + {{ range .tags }} + <div> + <strong>{{ .Name }}</strong> + <a href="/{{ $name }}/tree/{{ .Name }}/">browse</a> + <a href="/{{ $name }}/log/{{ .Name }}">log</a> + {{ if .Message }} + <pre>{{ .Message }}</pre> + </div> + {{ end }} + {{ end }} + </div> + {{ end }} + </main> + </body> +</html> +{{ end }}
A
web/templates/repo_tree.html
@@ -0,0 +1,73 @@
+{{ define "repo_tree" }} +<html> + <head> + {{ template "head" . }} + <title>{{ .name }}: tree ({{ .ref }})</title> + </head> + {{ template "repo_header" . }} + <body> + <main> + {{ $repo := .name }} + {{ $ref := .ref }} + {{ $parent := .parent }} + + <table class="tree"> + <thead> + <tr class="nohover"> + <th class="mode">mode</th> + <th class="size">size</th> + <th class="name">name</th> + </tr> + </thead> + <tbody> + {{ if $parent }} + <tr> + <td class="mode"></td> + <td class="size"></td> + <td class="name"><a href="/{{ $repo }}/tree/{{ $ref }}/{{ .dotdot }}">..</a></td> + </tr> + {{ end }} + + {{ range .files }} + {{ if not .IsFile }} + <tr> + <td class="mode">{{ .Mode }}</td> + <td class="size">{{ .Size }}</td> + <td class="name"> + {{ if $parent }} + <a href="/{{ $repo }}/tree/{{ $ref }}/{{ $parent }}/{{ .Name }}">{{ .Name }}/</a> + {{ else }} + <a href="/{{ $repo }}/tree/{{ $ref }}/{{ .Name }}">{{ .Name }}/</a> + {{ end }} + </td> + </tr> + {{ end }} + {{ end }} + + {{ range .files }} + {{ if .IsFile }} + <tr> + <td class="mode">{{ .Mode }}</td> + <td class="size">{{ .Size }}</td> + <td class="name"> + {{ if $parent }} + <a href="/{{ $repo }}/blob/{{ $ref }}/{{ $parent }}/{{ .Name }}">{{ .Name }}</a> + {{ else }} + <a href="/{{ $repo }}/blob/{{ $ref }}/{{ .Name }}">{{ .Name }}</a> + {{ end }} + </td> + </tr> + {{ end }} + {{ end }} + </tbody> + </table> + + <article> + <pre> + {{- if .readme }}{{ .readme }}{{- end -}} + </pre> + </article> + </main> + </body> +</html> +{{ end }}
A
web/web.go
@@ -0,0 +1,21 @@
+package web + +import ( + "embed" + "io/fs" +) + +var ( + //go:embed templates/* static/* + allFS embed.FS + TemplatesFS = fsSub(allFS, "templates") + StaticFS = fsSub(allFS, "static") +) + +func fsSub(fsys fs.FS, dir string) fs.FS { + f, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + return f +}