feat: init kepler
a16d3202
32 file(s) · +2230 −4
| 19 | 19 | - name: Determine which apps to build |
|
| 20 | 20 | id: filter |
|
| 21 | 21 | run: | |
|
| 22 | - | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]' |
|
| 22 | + | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]' |
|
| 23 | 23 | ||
| 24 | 24 | changed=$(git diff --name-only origin/${{ github.base_ref }}...HEAD) |
|
| 25 | 25 | ||
| 29 | 29 | fi |
|
| 30 | 30 | ||
| 31 | 31 | apps=() |
|
| 32 | - | for app in backup blobs bookmarks cellar easel feeds jotts library og posts shrink sipp; do |
|
| 32 | + | for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do |
|
| 33 | 33 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 34 | 34 | apps+=("\"${app}\"") |
|
| 35 | 35 | fi |
|
| 25 | 25 | - name: Determine which apps to build |
|
| 26 | 26 | id: filter |
|
| 27 | 27 | run: | |
|
| 28 | - | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","library","og","posts","shrink","sipp"]' |
|
| 28 | + | ALL='["backup","blobs","bookmarks","cellar","easel","feeds","jotts","kepler","library","og","posts","shrink","sipp"]' |
|
| 29 | 29 | ||
| 30 | 30 | # Tags: per-app (app/version) or bare (version) |
|
| 31 | 31 | if [[ "${GITHUB_REF}" == refs/tags/* ]]; then |
|
| 52 | 52 | fi |
|
| 53 | 53 | ||
| 54 | 54 | apps=() |
|
| 55 | - | for app in backup blobs bookmarks cellar easel feeds jotts library og posts shrink sipp; do |
|
| 55 | + | for app in backup blobs bookmarks cellar easel feeds jotts kepler library og posts shrink sipp; do |
|
| 56 | 56 | if echo "$changed" | grep -q "^apps/${app}/"; then |
|
| 57 | 57 | apps+=("\"${app}\"") |
|
| 58 | 58 | fi |
|
| 1 | + | # Bind address (use 0.0.0.0 in Docker) |
|
| 2 | + | HOST=127.0.0.1 |
|
| 3 | + | # Listen port |
|
| 4 | + | PORT=4747 |
|
| 5 | + | # Path to a directory containing bare git repos (softserve's <data>/repos works) |
|
| 6 | + | KEPLER_REPO_ROOT=./repos |
|
| 7 | + | # Site name shown in header and feed |
|
| 8 | + | KEPLER_SITE_NAME=kepler |
| 1 | + | # Build from repo root: docker build -t kepler -f apps/kepler/Dockerfile . |
|
| 2 | + | FROM golang:1.25-bookworm AS builder |
|
| 3 | + | WORKDIR /app |
|
| 4 | + | COPY pkg/ ./pkg/ |
|
| 5 | + | COPY apps/kepler/go.mod apps/kepler/go.sum ./apps/kepler/ |
|
| 6 | + | WORKDIR /app/apps/kepler |
|
| 7 | + | RUN go mod download |
|
| 8 | + | COPY apps/kepler/ ./ |
|
| 9 | + | RUN CGO_ENABLED=0 go build -o /kepler . |
|
| 10 | + | ||
| 11 | + | FROM debian:bookworm-slim |
|
| 12 | + | COPY --from=builder /kepler /usr/local/bin/kepler |
|
| 13 | + | WORKDIR /data |
|
| 14 | + | ENV HOST=0.0.0.0 |
|
| 15 | + | ENV PORT=4747 |
|
| 16 | + | ENV KEPLER_REPO_ROOT=/data/repos |
|
| 17 | + | EXPOSE 4747 |
|
| 18 | + | CMD ["kepler"] |
| 1 | + | # kepler |
|
| 2 | + | ||
| 3 | + | Read-only web view for on-disk git repositories. Drop-in compatible with |
|
| 4 | + | softserve's `<data>/repos` layout — point `KEPLER_REPO_ROOT` at it. |
|
| 5 | + | ||
| 6 | + | ## Features |
|
| 7 | + | ||
| 8 | + | - Repo index with description + last-commit time |
|
| 9 | + | - README rendering (goldmark) |
|
| 10 | + | - Tree / blob browsing at any ref (branch, tag, SHA) |
|
| 11 | + | - Syntax-highlighted source view (chroma) |
|
| 12 | + | - Raw file download |
|
| 13 | + | - Commit log with pagination |
|
| 14 | + | - Single-commit diff view |
|
| 15 | + | - Branches + tags page |
|
| 16 | + | - Archive download: `.tar.gz`, `.zip` |
|
| 17 | + | - Atom feed per repo |
|
| 18 | + | ||
| 19 | + | ## Run |
|
| 20 | + | ||
| 21 | + | ```bash |
|
| 22 | + | KEPLER_REPO_ROOT=~/.local/share/soft-serve/repos go run . |
|
| 23 | + | ``` |
|
| 24 | + | ||
| 25 | + | Then open <http://127.0.0.1:4747>. |
|
| 26 | + | ||
| 27 | + | ## Env |
|
| 28 | + | ||
| 29 | + | | Variable | Default | Notes | |
|
| 30 | + | |---|---|---| |
|
| 31 | + | | `HOST` | `127.0.0.1` | use `0.0.0.0` in Docker | |
|
| 32 | + | | `PORT` | `4747` | | |
|
| 33 | + | | `KEPLER_REPO_ROOT` | `./repos` | dir of bare repos (`*.git/`) or normal repos | |
|
| 34 | + | | `KEPLER_SITE_NAME` | `kepler` | shown in header + feed | |
|
| 35 | + | ||
| 36 | + | ## Repo discovery |
|
| 37 | + | ||
| 38 | + | Each entry of `KEPLER_REPO_ROOT` is treated as a candidate: |
|
| 39 | + | ||
| 40 | + | - `<name>.git/` → bare repo; display name = stripped basename. |
|
| 41 | + | - `<name>/.git/` → non-bare repo; display name = dirname. |
|
| 42 | + | ||
| 43 | + | `description` file in the repo directory shows on the index and repo home |
|
| 44 | + | (softserve writes this; the default placeholder is filtered out). |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "embed" |
|
| 5 | + | "html/template" |
|
| 6 | + | "log/slog" |
|
| 7 | + | "time" |
|
| 8 | + | ) |
|
| 9 | + | ||
| 10 | + | //go:embed templates/*.html static/* static/fonts/* |
|
| 11 | + | var appFS embed.FS |
|
| 12 | + | ||
| 13 | + | type App struct { |
|
| 14 | + | Log *slog.Logger |
|
| 15 | + | Templates map[string]*template.Template |
|
| 16 | + | RepoRoot string |
|
| 17 | + | SiteName string |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | type pageBase struct { |
|
| 21 | + | SiteName string |
|
| 22 | + | RepoName string |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | type indexPageData struct { |
|
| 26 | + | pageBase |
|
| 27 | + | Repos []RepoSummary |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | type repoPageData struct { |
|
| 31 | + | pageBase |
|
| 32 | + | Repo RepoSummary |
|
| 33 | + | Ref string |
|
| 34 | + | ReadmeHTML template.HTML |
|
| 35 | + | HasReadme bool |
|
| 36 | + | Branches []RefInfo |
|
| 37 | + | Tags []RefInfo |
|
| 38 | + | Commits []CommitInfo |
|
| 39 | + | DefaultRef string |
|
| 40 | + | LatestCommit CommitInfo |
|
| 41 | + | HasLatest bool |
|
| 42 | + | Entries []TreeEntry |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | type treePageData struct { |
|
| 46 | + | pageBase |
|
| 47 | + | Repo RepoSummary |
|
| 48 | + | Ref string |
|
| 49 | + | DefaultRef string |
|
| 50 | + | Path string |
|
| 51 | + | Breadcrumbs []Breadcrumb |
|
| 52 | + | Entries []TreeEntry |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | type blobPageData struct { |
|
| 56 | + | pageBase |
|
| 57 | + | Repo RepoSummary |
|
| 58 | + | Ref string |
|
| 59 | + | DefaultRef string |
|
| 60 | + | Path string |
|
| 61 | + | Breadcrumbs []Breadcrumb |
|
| 62 | + | Binary bool |
|
| 63 | + | Size int64 |
|
| 64 | + | HighlightedHTML template.HTML |
|
| 65 | + | } |
|
| 66 | + | ||
| 67 | + | type logPageData struct { |
|
| 68 | + | pageBase |
|
| 69 | + | Repo RepoSummary |
|
| 70 | + | Ref string |
|
| 71 | + | DefaultRef string |
|
| 72 | + | Commits []CommitInfo |
|
| 73 | + | Page int |
|
| 74 | + | NextPage int |
|
| 75 | + | PrevPage int |
|
| 76 | + | HasNext bool |
|
| 77 | + | HasPrev bool |
|
| 78 | + | } |
|
| 79 | + | ||
| 80 | + | type commitPageData struct { |
|
| 81 | + | pageBase |
|
| 82 | + | Repo RepoSummary |
|
| 83 | + | DefaultRef string |
|
| 84 | + | Commit CommitInfo |
|
| 85 | + | Files []FilePatch |
|
| 86 | + | Stats DiffStats |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | type refsPageData struct { |
|
| 90 | + | pageBase |
|
| 91 | + | Repo RepoSummary |
|
| 92 | + | DefaultRef string |
|
| 93 | + | Branches []RefInfo |
|
| 94 | + | Tags []RefInfo |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | type Breadcrumb struct { |
|
| 98 | + | Name string |
|
| 99 | + | Href string |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | type RepoSummary struct { |
|
| 103 | + | Name string |
|
| 104 | + | Description string |
|
| 105 | + | DefaultRef string |
|
| 106 | + | LastCommit time.Time |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | type RefInfo struct { |
|
| 110 | + | Name string |
|
| 111 | + | SHA string |
|
| 112 | + | Time time.Time |
|
| 113 | + | Author string |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | type CommitInfo struct { |
|
| 117 | + | SHA string |
|
| 118 | + | ShortSHA string |
|
| 119 | + | Author string |
|
| 120 | + | Email string |
|
| 121 | + | When time.Time |
|
| 122 | + | Subject string |
|
| 123 | + | Body string |
|
| 124 | + | ParentSHA string |
|
| 125 | + | } |
|
| 126 | + | ||
| 127 | + | type TreeEntry struct { |
|
| 128 | + | Name string |
|
| 129 | + | Path string |
|
| 130 | + | IsDir bool |
|
| 131 | + | Size int64 |
|
| 132 | + | Mode string |
|
| 133 | + | Commit CommitInfo |
|
| 134 | + | } |
|
| 135 | + | ||
| 136 | + | type FilePatch struct { |
|
| 137 | + | From string |
|
| 138 | + | To string |
|
| 139 | + | IsBin bool |
|
| 140 | + | Hunks []DiffHunk |
|
| 141 | + | Added int |
|
| 142 | + | Removed int |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | type DiffHunk struct { |
|
| 146 | + | Header string |
|
| 147 | + | Lines []DiffLine |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | type DiffLine struct { |
|
| 151 | + | Kind string // "ctx", "add", "del" |
|
| 152 | + | Text string |
|
| 153 | + | OldNum int |
|
| 154 | + | NewNum int |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | type DiffStats struct { |
|
| 158 | + | Files int |
|
| 159 | + | Added int |
|
| 160 | + | Removed int |
|
| 161 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "archive/tar" |
|
| 5 | + | "archive/zip" |
|
| 6 | + | "compress/gzip" |
|
| 7 | + | "io" |
|
| 8 | + | "net/http" |
|
| 9 | + | "strings" |
|
| 10 | + | ||
| 11 | + | "github.com/go-git/go-git/v5" |
|
| 12 | + | "github.com/go-git/go-git/v5/plumbing/filemode" |
|
| 13 | + | "github.com/go-git/go-git/v5/plumbing/object" |
|
| 14 | + | ) |
|
| 15 | + | ||
| 16 | + | func writeTarGz(w http.ResponseWriter, repo *git.Repository, ref, prefix string) error { |
|
| 17 | + | c, err := resolveRef(repo, ref) |
|
| 18 | + | if err != nil { |
|
| 19 | + | return err |
|
| 20 | + | } |
|
| 21 | + | tree, err := c.Tree() |
|
| 22 | + | if err != nil { |
|
| 23 | + | return err |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | gz := gzip.NewWriter(w) |
|
| 27 | + | defer gz.Close() |
|
| 28 | + | tw := tar.NewWriter(gz) |
|
| 29 | + | defer tw.Close() |
|
| 30 | + | ||
| 31 | + | return tree.Files().ForEach(func(f *object.File) error { |
|
| 32 | + | mode := int64(0644) |
|
| 33 | + | if f.Mode == filemode.Executable { |
|
| 34 | + | mode = 0755 |
|
| 35 | + | } |
|
| 36 | + | hdr := &tar.Header{ |
|
| 37 | + | Name: prefix + "/" + f.Name, |
|
| 38 | + | Mode: mode, |
|
| 39 | + | Size: f.Size, |
|
| 40 | + | } |
|
| 41 | + | if err := tw.WriteHeader(hdr); err != nil { |
|
| 42 | + | return err |
|
| 43 | + | } |
|
| 44 | + | r, err := f.Blob.Reader() |
|
| 45 | + | if err != nil { |
|
| 46 | + | return err |
|
| 47 | + | } |
|
| 48 | + | _, err = io.Copy(tw, r) |
|
| 49 | + | r.Close() |
|
| 50 | + | return err |
|
| 51 | + | }) |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | func writeZip(w http.ResponseWriter, repo *git.Repository, ref, prefix string) error { |
|
| 55 | + | c, err := resolveRef(repo, ref) |
|
| 56 | + | if err != nil { |
|
| 57 | + | return err |
|
| 58 | + | } |
|
| 59 | + | tree, err := c.Tree() |
|
| 60 | + | if err != nil { |
|
| 61 | + | return err |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | zw := zip.NewWriter(w) |
|
| 65 | + | defer zw.Close() |
|
| 66 | + | ||
| 67 | + | return tree.Files().ForEach(func(f *object.File) error { |
|
| 68 | + | zf, err := zw.Create(prefix + "/" + f.Name) |
|
| 69 | + | if err != nil { |
|
| 70 | + | return err |
|
| 71 | + | } |
|
| 72 | + | r, err := f.Blob.Reader() |
|
| 73 | + | if err != nil { |
|
| 74 | + | return err |
|
| 75 | + | } |
|
| 76 | + | _, err = io.Copy(zf, r) |
|
| 77 | + | r.Close() |
|
| 78 | + | return err |
|
| 79 | + | }) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | func parseArchiveName(name string) (ref, format string, ok bool) { |
|
| 83 | + | if strings.HasSuffix(name, ".tar.gz") { |
|
| 84 | + | return strings.TrimSuffix(name, ".tar.gz"), "tar.gz", true |
|
| 85 | + | } |
|
| 86 | + | if strings.HasSuffix(name, ".tgz") { |
|
| 87 | + | return strings.TrimSuffix(name, ".tgz"), "tar.gz", true |
|
| 88 | + | } |
|
| 89 | + | if strings.HasSuffix(name, ".zip") { |
|
| 90 | + | return strings.TrimSuffix(name, ".zip"), "zip", true |
|
| 91 | + | } |
|
| 92 | + | return "", "", false |
|
| 93 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "encoding/xml" |
|
| 5 | + | "fmt" |
|
| 6 | + | "time" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | type atomFeed struct { |
|
| 10 | + | XMLName xml.Name `xml:"feed"` |
|
| 11 | + | XMLNS string `xml:"xmlns,attr"` |
|
| 12 | + | Title string `xml:"title"` |
|
| 13 | + | ID string `xml:"id"` |
|
| 14 | + | Updated string `xml:"updated"` |
|
| 15 | + | Link []atomLink `xml:"link"` |
|
| 16 | + | Entries []atomEntry `xml:"entry"` |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | type atomLink struct { |
|
| 20 | + | Rel string `xml:"rel,attr,omitempty"` |
|
| 21 | + | Href string `xml:"href,attr"` |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | type atomEntry struct { |
|
| 25 | + | Title string `xml:"title"` |
|
| 26 | + | ID string `xml:"id"` |
|
| 27 | + | Updated string `xml:"updated"` |
|
| 28 | + | Link atomLink `xml:"link"` |
|
| 29 | + | Author atomAuthor `xml:"author"` |
|
| 30 | + | Summary string `xml:"summary"` |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | type atomAuthor struct { |
|
| 34 | + | Name string `xml:"name"` |
|
| 35 | + | Email string `xml:"email,omitempty"` |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | func buildAtomFeed(siteName, repoName, baseURL string, commits []CommitInfo) ([]byte, error) { |
|
| 39 | + | feed := atomFeed{ |
|
| 40 | + | XMLNS: "http://www.w3.org/2005/Atom", |
|
| 41 | + | Title: fmt.Sprintf("%s / %s — commits", siteName, repoName), |
|
| 42 | + | ID: baseURL + "/r/" + repoName, |
|
| 43 | + | Updated: time.Now().UTC().Format(time.RFC3339), |
|
| 44 | + | Link: []atomLink{ |
|
| 45 | + | {Href: baseURL + "/r/" + repoName}, |
|
| 46 | + | {Rel: "self", Href: baseURL + "/r/" + repoName + "/atom.xml"}, |
|
| 47 | + | }, |
|
| 48 | + | } |
|
| 49 | + | if len(commits) > 0 { |
|
| 50 | + | feed.Updated = commits[0].When.UTC().Format(time.RFC3339) |
|
| 51 | + | } |
|
| 52 | + | for _, c := range commits { |
|
| 53 | + | feed.Entries = append(feed.Entries, atomEntry{ |
|
| 54 | + | Title: c.Subject, |
|
| 55 | + | ID: baseURL + "/r/" + repoName + "/commit/" + c.SHA, |
|
| 56 | + | Updated: c.When.UTC().Format(time.RFC3339), |
|
| 57 | + | Link: atomLink{Href: baseURL + "/r/" + repoName + "/commit/" + c.SHA}, |
|
| 58 | + | Author: atomAuthor{Name: c.Author, Email: c.Email}, |
|
| 59 | + | Summary: c.Body, |
|
| 60 | + | }) |
|
| 61 | + | } |
|
| 62 | + | out, err := xml.MarshalIndent(feed, "", " ") |
|
| 63 | + | if err != nil { |
|
| 64 | + | return nil, err |
|
| 65 | + | } |
|
| 66 | + | return append([]byte(xml.Header), out...), nil |
|
| 67 | + | } |
| 1 | + | services: |
|
| 2 | + | app: |
|
| 3 | + | build: |
|
| 4 | + | context: ../.. |
|
| 5 | + | dockerfile: apps/kepler/Dockerfile |
|
| 6 | + | ports: |
|
| 7 | + | - "${PORT:-4747}:${PORT:-4747}" |
|
| 8 | + | environment: |
|
| 9 | + | - HOST=0.0.0.0 |
|
| 10 | + | - PORT=${PORT:-4747} |
|
| 11 | + | - KEPLER_REPO_ROOT=/data/repos |
|
| 12 | + | - KEPLER_SITE_NAME=${KEPLER_SITE_NAME:-kepler} |
|
| 13 | + | volumes: |
|
| 14 | + | - ${KEPLER_REPO_ROOT:-./repos}:/data/repos:ro |
|
| 15 | + | restart: unless-stopped |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "errors" |
|
| 5 | + | "io" |
|
| 6 | + | "sort" |
|
| 7 | + | "strings" |
|
| 8 | + | ||
| 9 | + | "github.com/go-git/go-git/v5" |
|
| 10 | + | "github.com/go-git/go-git/v5/plumbing" |
|
| 11 | + | "github.com/go-git/go-git/v5/plumbing/filemode" |
|
| 12 | + | "github.com/go-git/go-git/v5/plumbing/object" |
|
| 13 | + | ) |
|
| 14 | + | ||
| 15 | + | func resolveRef(repo *git.Repository, ref string) (*object.Commit, error) { |
|
| 16 | + | if ref == "" { |
|
| 17 | + | ref = defaultBranchName(repo) |
|
| 18 | + | } |
|
| 19 | + | hash, err := repo.ResolveRevision(plumbing.Revision(ref)) |
|
| 20 | + | if err != nil { |
|
| 21 | + | return nil, err |
|
| 22 | + | } |
|
| 23 | + | return repo.CommitObject(*hash) |
|
| 24 | + | } |
|
| 25 | + | ||
| 26 | + | func listBranches(repo *git.Repository) ([]RefInfo, error) { |
|
| 27 | + | iter, err := repo.Branches() |
|
| 28 | + | if err != nil { |
|
| 29 | + | return nil, err |
|
| 30 | + | } |
|
| 31 | + | var out []RefInfo |
|
| 32 | + | _ = iter.ForEach(func(r *plumbing.Reference) error { |
|
| 33 | + | info := RefInfo{Name: r.Name().Short(), SHA: r.Hash().String()} |
|
| 34 | + | if c, err := repo.CommitObject(r.Hash()); err == nil { |
|
| 35 | + | info.Time = c.Author.When |
|
| 36 | + | info.Author = c.Author.Name |
|
| 37 | + | } |
|
| 38 | + | out = append(out, info) |
|
| 39 | + | return nil |
|
| 40 | + | }) |
|
| 41 | + | sort.Slice(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) }) |
|
| 42 | + | return out, nil |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | func listTags(repo *git.Repository) ([]RefInfo, error) { |
|
| 46 | + | iter, err := repo.Tags() |
|
| 47 | + | if err != nil { |
|
| 48 | + | return nil, err |
|
| 49 | + | } |
|
| 50 | + | var out []RefInfo |
|
| 51 | + | _ = iter.ForEach(func(r *plumbing.Reference) error { |
|
| 52 | + | info := RefInfo{Name: r.Name().Short(), SHA: r.Hash().String()} |
|
| 53 | + | if tag, err := repo.TagObject(r.Hash()); err == nil { |
|
| 54 | + | info.Time = tag.Tagger.When |
|
| 55 | + | info.Author = tag.Tagger.Name |
|
| 56 | + | if c, err := tag.Commit(); err == nil { |
|
| 57 | + | info.SHA = c.Hash.String() |
|
| 58 | + | } |
|
| 59 | + | } else if c, err := repo.CommitObject(r.Hash()); err == nil { |
|
| 60 | + | info.Time = c.Author.When |
|
| 61 | + | info.Author = c.Author.Name |
|
| 62 | + | } |
|
| 63 | + | out = append(out, info) |
|
| 64 | + | return nil |
|
| 65 | + | }) |
|
| 66 | + | sort.Slice(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) }) |
|
| 67 | + | return out, nil |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | func commitLog(repo *git.Repository, ref string, page, perPage int) ([]CommitInfo, bool, error) { |
|
| 71 | + | head, err := resolveRef(repo, ref) |
|
| 72 | + | if err != nil { |
|
| 73 | + | return nil, false, err |
|
| 74 | + | } |
|
| 75 | + | iter, err := repo.Log(&git.LogOptions{From: head.Hash}) |
|
| 76 | + | if err != nil { |
|
| 77 | + | return nil, false, err |
|
| 78 | + | } |
|
| 79 | + | defer iter.Close() |
|
| 80 | + | ||
| 81 | + | skip := page * perPage |
|
| 82 | + | var out []CommitInfo |
|
| 83 | + | i := 0 |
|
| 84 | + | hasNext := false |
|
| 85 | + | err = iter.ForEach(func(c *object.Commit) error { |
|
| 86 | + | if i < skip { |
|
| 87 | + | i++ |
|
| 88 | + | return nil |
|
| 89 | + | } |
|
| 90 | + | if len(out) >= perPage { |
|
| 91 | + | hasNext = true |
|
| 92 | + | return io.EOF |
|
| 93 | + | } |
|
| 94 | + | out = append(out, toCommitInfo(c)) |
|
| 95 | + | i++ |
|
| 96 | + | return nil |
|
| 97 | + | }) |
|
| 98 | + | if err != nil && !errors.Is(err, io.EOF) { |
|
| 99 | + | return nil, false, err |
|
| 100 | + | } |
|
| 101 | + | return out, hasNext, nil |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | func toCommitInfo(c *object.Commit) CommitInfo { |
|
| 105 | + | subject, body := splitCommitMessage(c.Message) |
|
| 106 | + | parent := "" |
|
| 107 | + | if c.NumParents() > 0 { |
|
| 108 | + | parent = c.ParentHashes[0].String() |
|
| 109 | + | } |
|
| 110 | + | return CommitInfo{ |
|
| 111 | + | SHA: c.Hash.String(), |
|
| 112 | + | ShortSHA: c.Hash.String()[:8], |
|
| 113 | + | Author: c.Author.Name, |
|
| 114 | + | Email: c.Author.Email, |
|
| 115 | + | When: c.Author.When, |
|
| 116 | + | Subject: subject, |
|
| 117 | + | Body: body, |
|
| 118 | + | ParentSHA: parent, |
|
| 119 | + | } |
|
| 120 | + | } |
|
| 121 | + | ||
| 122 | + | func splitCommitMessage(msg string) (subject, body string) { |
|
| 123 | + | msg = strings.TrimRight(msg, "\n") |
|
| 124 | + | if i := strings.Index(msg, "\n"); i >= 0 { |
|
| 125 | + | return strings.TrimSpace(msg[:i]), strings.TrimSpace(msg[i+1:]) |
|
| 126 | + | } |
|
| 127 | + | return msg, "" |
|
| 128 | + | } |
|
| 129 | + | ||
| 130 | + | func treeAt(repo *git.Repository, ref, path string) ([]TreeEntry, *object.Tree, error) { |
|
| 131 | + | c, err := resolveRef(repo, ref) |
|
| 132 | + | if err != nil { |
|
| 133 | + | return nil, nil, err |
|
| 134 | + | } |
|
| 135 | + | root, err := c.Tree() |
|
| 136 | + | if err != nil { |
|
| 137 | + | return nil, nil, err |
|
| 138 | + | } |
|
| 139 | + | tree := root |
|
| 140 | + | if path != "" { |
|
| 141 | + | t, err := root.Tree(path) |
|
| 142 | + | if err != nil { |
|
| 143 | + | return nil, nil, err |
|
| 144 | + | } |
|
| 145 | + | tree = t |
|
| 146 | + | } |
|
| 147 | + | var out []TreeEntry |
|
| 148 | + | for _, e := range tree.Entries { |
|
| 149 | + | entryPath := e.Name |
|
| 150 | + | if path != "" { |
|
| 151 | + | entryPath = path + "/" + e.Name |
|
| 152 | + | } |
|
| 153 | + | te := TreeEntry{ |
|
| 154 | + | Name: e.Name, |
|
| 155 | + | Path: entryPath, |
|
| 156 | + | IsDir: e.Mode == filemode.Dir, |
|
| 157 | + | Mode: e.Mode.String(), |
|
| 158 | + | } |
|
| 159 | + | if !te.IsDir { |
|
| 160 | + | if f, err := tree.File(e.Name); err == nil { |
|
| 161 | + | te.Size = f.Size |
|
| 162 | + | } |
|
| 163 | + | } |
|
| 164 | + | out = append(out, te) |
|
| 165 | + | } |
|
| 166 | + | sort.Slice(out, func(i, j int) bool { |
|
| 167 | + | if out[i].IsDir != out[j].IsDir { |
|
| 168 | + | return out[i].IsDir |
|
| 169 | + | } |
|
| 170 | + | return out[i].Name < out[j].Name |
|
| 171 | + | }) |
|
| 172 | + | return out, tree, nil |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | func blobAt(repo *git.Repository, ref, path string) (string, int64, bool, error) { |
|
| 176 | + | c, err := resolveRef(repo, ref) |
|
| 177 | + | if err != nil { |
|
| 178 | + | return "", 0, false, err |
|
| 179 | + | } |
|
| 180 | + | tree, err := c.Tree() |
|
| 181 | + | if err != nil { |
|
| 182 | + | return "", 0, false, err |
|
| 183 | + | } |
|
| 184 | + | f, err := tree.File(path) |
|
| 185 | + | if err != nil { |
|
| 186 | + | return "", 0, false, err |
|
| 187 | + | } |
|
| 188 | + | bin, err := f.IsBinary() |
|
| 189 | + | if err != nil { |
|
| 190 | + | return "", 0, false, err |
|
| 191 | + | } |
|
| 192 | + | if bin { |
|
| 193 | + | return "", f.Size, true, nil |
|
| 194 | + | } |
|
| 195 | + | content, err := f.Contents() |
|
| 196 | + | if err != nil { |
|
| 197 | + | return "", 0, false, err |
|
| 198 | + | } |
|
| 199 | + | return content, f.Size, false, nil |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | func blobBytes(repo *git.Repository, ref, path string) ([]byte, error) { |
|
| 203 | + | c, err := resolveRef(repo, ref) |
|
| 204 | + | if err != nil { |
|
| 205 | + | return nil, err |
|
| 206 | + | } |
|
| 207 | + | tree, err := c.Tree() |
|
| 208 | + | if err != nil { |
|
| 209 | + | return nil, err |
|
| 210 | + | } |
|
| 211 | + | f, err := tree.File(path) |
|
| 212 | + | if err != nil { |
|
| 213 | + | return nil, err |
|
| 214 | + | } |
|
| 215 | + | r, err := f.Blob.Reader() |
|
| 216 | + | if err != nil { |
|
| 217 | + | return nil, err |
|
| 218 | + | } |
|
| 219 | + | defer r.Close() |
|
| 220 | + | return io.ReadAll(r) |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | func commitDiff(repo *git.Repository, sha string) (CommitInfo, []FilePatch, DiffStats, error) { |
|
| 224 | + | h := plumbing.NewHash(sha) |
|
| 225 | + | c, err := repo.CommitObject(h) |
|
| 226 | + | if err != nil { |
|
| 227 | + | return CommitInfo{}, nil, DiffStats{}, err |
|
| 228 | + | } |
|
| 229 | + | info := toCommitInfo(c) |
|
| 230 | + | ||
| 231 | + | var parentTree *object.Tree |
|
| 232 | + | if c.NumParents() > 0 { |
|
| 233 | + | p, err := c.Parent(0) |
|
| 234 | + | if err != nil { |
|
| 235 | + | return info, nil, DiffStats{}, err |
|
| 236 | + | } |
|
| 237 | + | parentTree, err = p.Tree() |
|
| 238 | + | if err != nil { |
|
| 239 | + | return info, nil, DiffStats{}, err |
|
| 240 | + | } |
|
| 241 | + | } |
|
| 242 | + | thisTree, err := c.Tree() |
|
| 243 | + | if err != nil { |
|
| 244 | + | return info, nil, DiffStats{}, err |
|
| 245 | + | } |
|
| 246 | + | patch, err := diffTrees(parentTree, thisTree) |
|
| 247 | + | if err != nil { |
|
| 248 | + | return info, nil, DiffStats{}, err |
|
| 249 | + | } |
|
| 250 | + | files, stats := convertPatch(patch) |
|
| 251 | + | return info, files, stats, nil |
|
| 252 | + | } |
|
| 253 | + | ||
| 254 | + | func diffTrees(from, to *object.Tree) (*object.Patch, error) { |
|
| 255 | + | if from == nil { |
|
| 256 | + | return (&object.Tree{}).Patch(to) |
|
| 257 | + | } |
|
| 258 | + | return from.Patch(to) |
|
| 259 | + | } |
| 1 | + | module github.com/stevedylandev/andromeda/apps/kepler |
|
| 2 | + | ||
| 3 | + | go 1.25 |
|
| 4 | + | ||
| 5 | + | require ( |
|
| 6 | + | github.com/go-git/go-git/v5 v5.13.1 |
|
| 7 | + | github.com/stevedylandev/andromeda/pkg/config v0.0.0 |
|
| 8 | + | github.com/stevedylandev/andromeda/pkg/web v0.0.0 |
|
| 9 | + | github.com/yuin/goldmark v1.7.8 |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | require ( |
|
| 13 | + | dario.cat/mergo v1.0.0 // indirect |
|
| 14 | + | github.com/Microsoft/go-winio v0.6.1 // indirect |
|
| 15 | + | github.com/ProtonMail/go-crypto v1.1.3 // indirect |
|
| 16 | + | github.com/cloudflare/circl v1.3.7 // indirect |
|
| 17 | + | github.com/cyphar/filepath-securejoin v0.3.6 // indirect |
|
| 18 | + | github.com/emirpasic/gods v1.18.1 // indirect |
|
| 19 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect |
|
| 20 | + | github.com/go-git/go-billy/v5 v5.6.1 // indirect |
|
| 21 | + | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect |
|
| 22 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect |
|
| 23 | + | github.com/kevinburke/ssh_config v1.2.0 // indirect |
|
| 24 | + | github.com/pjbgf/sha1cd v0.3.0 // indirect |
|
| 25 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect |
|
| 26 | + | github.com/skeema/knownhosts v1.3.0 // indirect |
|
| 27 | + | github.com/xanzy/ssh-agent v0.3.3 // indirect |
|
| 28 | + | golang.org/x/crypto v0.31.0 // indirect |
|
| 29 | + | golang.org/x/mod v0.17.0 // indirect |
|
| 30 | + | golang.org/x/net v0.33.0 // indirect |
|
| 31 | + | golang.org/x/sync v0.10.0 // indirect |
|
| 32 | + | golang.org/x/sys v0.28.0 // indirect |
|
| 33 | + | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect |
|
| 34 | + | gopkg.in/warnings.v0 v0.1.2 // indirect |
|
| 35 | + | ) |
|
| 36 | + | ||
| 37 | + | replace ( |
|
| 38 | + | github.com/stevedylandev/andromeda/pkg/config => ../../pkg/config |
|
| 39 | + | github.com/stevedylandev/andromeda/pkg/web => ../../pkg/web |
|
| 40 | + | ) |
| 1 | + | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= |
|
| 2 | + | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= |
|
| 3 | + | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= |
|
| 4 | + | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= |
|
| 5 | + | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= |
|
| 6 | + | github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= |
|
| 7 | + | github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= |
|
| 8 | + | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= |
|
| 9 | + | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= |
|
| 10 | + | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= |
|
| 11 | + | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= |
|
| 12 | + | github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= |
|
| 13 | + | github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= |
|
| 14 | + | github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM= |
|
| 15 | + | github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= |
|
| 16 | + | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
| 17 | + | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= |
|
| 18 | + | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= |
|
| 19 | + | github.com/elazarl/goproxy v1.2.3 h1:xwIyKHbaP5yfT6O9KIeYJR5549MXRQkoQMRXGztz8YQ= |
|
| 20 | + | github.com/elazarl/goproxy v1.2.3/go.mod h1:YfEbZtqP4AetfO6d40vWchF3znWX7C7Vd6ZMfdL8z64= |
|
| 21 | + | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= |
|
| 22 | + | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= |
|
| 23 | + | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= |
|
| 24 | + | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= |
|
| 25 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= |
|
| 26 | + | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= |
|
| 27 | + | github.com/go-git/go-billy/v5 v5.6.1 h1:u+dcrgaguSSkbjzHwelEjc0Yj300NUevrrPphk/SoRA= |
|
| 28 | + | github.com/go-git/go-billy/v5 v5.6.1/go.mod h1:0AsLr1z2+Uksi4NlElmMblP5rPcDZNRCD8ujZCRR2BE= |
|
| 29 | + | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= |
|
| 30 | + | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= |
|
| 31 | + | github.com/go-git/go-git/v5 v5.13.1 h1:DAQ9APonnlvSWpvolXWIuV6Q6zXy2wHbN4cVlNR5Q+M= |
|
| 32 | + | github.com/go-git/go-git/v5 v5.13.1/go.mod h1:qryJB4cSBoq3FRoBRf5A77joojuBcmPJ0qu3XXXVixc= |
|
| 33 | + | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= |
|
| 34 | + | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= |
|
| 35 | + | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= |
|
| 36 | + | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
|
| 37 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= |
|
| 38 | + | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= |
|
| 39 | + | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= |
|
| 40 | + | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= |
|
| 41 | + | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= |
|
| 42 | + | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= |
|
| 43 | + | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= |
|
| 44 | + | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= |
|
| 45 | + | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= |
|
| 46 | + | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= |
|
| 47 | + | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= |
|
| 48 | + | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= |
|
| 49 | + | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= |
|
| 50 | + | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= |
|
| 51 | + | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= |
|
| 52 | + | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= |
|
| 53 | + | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= |
|
| 54 | + | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= |
|
| 55 | + | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
|
| 56 | + | github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= |
|
| 57 | + | github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= |
|
| 58 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= |
|
| 59 | + | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= |
|
| 60 | + | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= |
|
| 61 | + | github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= |
|
| 62 | + | github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= |
|
| 63 | + | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= |
|
| 64 | + | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= |
|
| 65 | + | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= |
|
| 66 | + | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= |
|
| 67 | + | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= |
|
| 68 | + | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= |
|
| 69 | + | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= |
|
| 70 | + | github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= |
|
| 71 | + | github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= |
|
| 72 | + | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= |
|
| 73 | + | golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= |
|
| 74 | + | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= |
|
| 75 | + | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= |
|
| 76 | + | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= |
|
| 77 | + | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= |
|
| 78 | + | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
|
| 79 | + | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= |
|
| 80 | + | golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= |
|
| 81 | + | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= |
|
| 82 | + | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= |
|
| 83 | + | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= |
|
| 84 | + | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 85 | + | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 86 | + | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 87 | + | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= |
|
| 88 | + | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 89 | + | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
|
| 90 | + | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= |
|
| 91 | + | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
|
| 92 | + | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= |
|
| 93 | + | golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= |
|
| 94 | + | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= |
|
| 95 | + | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= |
|
| 96 | + | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= |
|
| 97 | + | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= |
|
| 98 | + | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
|
| 99 | + | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= |
|
| 100 | + | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= |
|
| 101 | + | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|
| 102 | + | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= |
|
| 103 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= |
|
| 104 | + | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= |
|
| 105 | + | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= |
|
| 106 | + | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= |
|
| 107 | + | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= |
|
| 108 | + | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= |
|
| 109 | + | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= |
|
| 110 | + | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | "strconv" |
|
| 6 | + | "strings" |
|
| 7 | + | ) |
|
| 8 | + | ||
| 9 | + | const commitsPerPage = 30 |
|
| 10 | + | ||
| 11 | + | func (a *App) base(repoName string) pageBase { |
|
| 12 | + | return pageBase{SiteName: a.SiteName, RepoName: repoName} |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | func (a *App) indexHandler(w http.ResponseWriter, r *http.Request) { |
|
| 16 | + | repos, err := a.listRepos() |
|
| 17 | + | if err != nil { |
|
| 18 | + | a.Log.Error("list repos failed", "err", err) |
|
| 19 | + | http.Error(w, "failed to list repos", http.StatusInternalServerError) |
|
| 20 | + | return |
|
| 21 | + | } |
|
| 22 | + | a.renderPage(w, "index.html", indexPageData{pageBase: a.base(""), Repos: repos}) |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | func (a *App) repoHandler(w http.ResponseWriter, r *http.Request) { |
|
| 26 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 27 | + | if err != nil { |
|
| 28 | + | http.NotFound(w, r) |
|
| 29 | + | return |
|
| 30 | + | } |
|
| 31 | + | branches, _ := listBranches(repo) |
|
| 32 | + | tags, _ := listTags(repo) |
|
| 33 | + | commits, _, _ := commitLog(repo, summary.DefaultRef, 0, 1) |
|
| 34 | + | entries, _, _ := treeAt(repo, summary.DefaultRef, "") |
|
| 35 | + | ||
| 36 | + | var latest CommitInfo |
|
| 37 | + | if len(commits) > 0 { |
|
| 38 | + | latest = commits[0] |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | data := repoPageData{ |
|
| 42 | + | pageBase: a.base(summary.Name), |
|
| 43 | + | Repo: summary, |
|
| 44 | + | Ref: summary.DefaultRef, |
|
| 45 | + | DefaultRef: summary.DefaultRef, |
|
| 46 | + | Branches: branches, |
|
| 47 | + | Tags: tags, |
|
| 48 | + | Commits: commits, |
|
| 49 | + | LatestCommit: latest, |
|
| 50 | + | HasLatest: len(commits) > 0, |
|
| 51 | + | Entries: entries, |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | if c, err := resolveRef(repo, summary.DefaultRef); err == nil { |
|
| 55 | + | if tree, err := c.Tree(); err == nil { |
|
| 56 | + | if src, ok := findReadme(tree); ok { |
|
| 57 | + | if html, err := renderMarkdown(src); err == nil { |
|
| 58 | + | data.ReadmeHTML = html |
|
| 59 | + | data.HasReadme = true |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | } |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | a.renderPage(w, "repo.html", data) |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | func (a *App) treeHandler(w http.ResponseWriter, r *http.Request) { |
|
| 69 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 70 | + | if err != nil { |
|
| 71 | + | http.NotFound(w, r) |
|
| 72 | + | return |
|
| 73 | + | } |
|
| 74 | + | ref := r.PathValue("ref") |
|
| 75 | + | subPath := strings.Trim(r.PathValue("path"), "/") |
|
| 76 | + | ||
| 77 | + | entries, _, err := treeAt(repo, ref, subPath) |
|
| 78 | + | if err != nil { |
|
| 79 | + | http.NotFound(w, r) |
|
| 80 | + | return |
|
| 81 | + | } |
|
| 82 | + | ||
| 83 | + | data := treePageData{ |
|
| 84 | + | pageBase: a.base(summary.Name), |
|
| 85 | + | Repo: summary, |
|
| 86 | + | Ref: ref, |
|
| 87 | + | DefaultRef: summary.DefaultRef, |
|
| 88 | + | Path: subPath, |
|
| 89 | + | Breadcrumbs: makeBreadcrumbs(summary.Name, ref, subPath), |
|
| 90 | + | Entries: entries, |
|
| 91 | + | } |
|
| 92 | + | a.renderPage(w, "tree.html", data) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | func (a *App) blobHandler(w http.ResponseWriter, r *http.Request) { |
|
| 96 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 97 | + | if err != nil { |
|
| 98 | + | http.NotFound(w, r) |
|
| 99 | + | return |
|
| 100 | + | } |
|
| 101 | + | ref := r.PathValue("ref") |
|
| 102 | + | subPath := strings.Trim(r.PathValue("path"), "/") |
|
| 103 | + | ||
| 104 | + | content, size, isBin, err := blobAt(repo, ref, subPath) |
|
| 105 | + | if err != nil { |
|
| 106 | + | http.NotFound(w, r) |
|
| 107 | + | return |
|
| 108 | + | } |
|
| 109 | + | ||
| 110 | + | data := blobPageData{ |
|
| 111 | + | pageBase: a.base(summary.Name), |
|
| 112 | + | Repo: summary, |
|
| 113 | + | Ref: ref, |
|
| 114 | + | DefaultRef: summary.DefaultRef, |
|
| 115 | + | Path: subPath, |
|
| 116 | + | Breadcrumbs: makeBreadcrumbs(summary.Name, ref, subPath), |
|
| 117 | + | Binary: isBin, |
|
| 118 | + | Size: size, |
|
| 119 | + | } |
|
| 120 | + | if !isBin { |
|
| 121 | + | data.HighlightedHTML = renderBlobLines(content) |
|
| 122 | + | } |
|
| 123 | + | a.renderPage(w, "blob.html", data) |
|
| 124 | + | } |
|
| 125 | + | ||
| 126 | + | func (a *App) rawHandler(w http.ResponseWriter, r *http.Request) { |
|
| 127 | + | repo, _, err := a.openRepo(r.PathValue("repo")) |
|
| 128 | + | if err != nil { |
|
| 129 | + | http.NotFound(w, r) |
|
| 130 | + | return |
|
| 131 | + | } |
|
| 132 | + | ref := r.PathValue("ref") |
|
| 133 | + | subPath := strings.Trim(r.PathValue("path"), "/") |
|
| 134 | + | ||
| 135 | + | data, err := blobBytes(repo, ref, subPath) |
|
| 136 | + | if err != nil { |
|
| 137 | + | http.NotFound(w, r) |
|
| 138 | + | return |
|
| 139 | + | } |
|
| 140 | + | ct := http.DetectContentType(data) |
|
| 141 | + | w.Header().Set("Content-Type", ct) |
|
| 142 | + | _, _ = w.Write(data) |
|
| 143 | + | } |
|
| 144 | + | ||
| 145 | + | func (a *App) logHandler(w http.ResponseWriter, r *http.Request) { |
|
| 146 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 147 | + | if err != nil { |
|
| 148 | + | http.NotFound(w, r) |
|
| 149 | + | return |
|
| 150 | + | } |
|
| 151 | + | ref := r.PathValue("ref") |
|
| 152 | + | page := 0 |
|
| 153 | + | if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { |
|
| 154 | + | page = p |
|
| 155 | + | } |
|
| 156 | + | commits, hasNext, err := commitLog(repo, ref, page, commitsPerPage) |
|
| 157 | + | if err != nil { |
|
| 158 | + | http.NotFound(w, r) |
|
| 159 | + | return |
|
| 160 | + | } |
|
| 161 | + | a.renderPage(w, "log.html", logPageData{ |
|
| 162 | + | pageBase: a.base(summary.Name), |
|
| 163 | + | Repo: summary, |
|
| 164 | + | Ref: ref, |
|
| 165 | + | DefaultRef: summary.DefaultRef, |
|
| 166 | + | Commits: commits, |
|
| 167 | + | Page: page, |
|
| 168 | + | NextPage: page + 1, |
|
| 169 | + | PrevPage: page - 1, |
|
| 170 | + | HasNext: hasNext, |
|
| 171 | + | HasPrev: page > 0, |
|
| 172 | + | }) |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | func (a *App) commitHandler(w http.ResponseWriter, r *http.Request) { |
|
| 176 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 177 | + | if err != nil { |
|
| 178 | + | http.NotFound(w, r) |
|
| 179 | + | return |
|
| 180 | + | } |
|
| 181 | + | sha := r.PathValue("sha") |
|
| 182 | + | info, files, stats, err := commitDiff(repo, sha) |
|
| 183 | + | if err != nil { |
|
| 184 | + | http.NotFound(w, r) |
|
| 185 | + | return |
|
| 186 | + | } |
|
| 187 | + | a.renderPage(w, "commit.html", commitPageData{ |
|
| 188 | + | pageBase: a.base(summary.Name), |
|
| 189 | + | Repo: summary, |
|
| 190 | + | DefaultRef: summary.DefaultRef, |
|
| 191 | + | Commit: info, |
|
| 192 | + | Files: files, |
|
| 193 | + | Stats: stats, |
|
| 194 | + | }) |
|
| 195 | + | } |
|
| 196 | + | ||
| 197 | + | func (a *App) refsHandler(w http.ResponseWriter, r *http.Request) { |
|
| 198 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 199 | + | if err != nil { |
|
| 200 | + | http.NotFound(w, r) |
|
| 201 | + | return |
|
| 202 | + | } |
|
| 203 | + | branches, _ := listBranches(repo) |
|
| 204 | + | tags, _ := listTags(repo) |
|
| 205 | + | a.renderPage(w, "refs.html", refsPageData{ |
|
| 206 | + | pageBase: a.base(summary.Name), |
|
| 207 | + | Repo: summary, |
|
| 208 | + | DefaultRef: summary.DefaultRef, |
|
| 209 | + | Branches: branches, |
|
| 210 | + | Tags: tags, |
|
| 211 | + | }) |
|
| 212 | + | } |
|
| 213 | + | ||
| 214 | + | func (a *App) archiveHandler(w http.ResponseWriter, r *http.Request) { |
|
| 215 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 216 | + | if err != nil { |
|
| 217 | + | http.NotFound(w, r) |
|
| 218 | + | return |
|
| 219 | + | } |
|
| 220 | + | ref, format, ok := parseArchiveName(r.PathValue("name")) |
|
| 221 | + | if !ok { |
|
| 222 | + | http.NotFound(w, r) |
|
| 223 | + | return |
|
| 224 | + | } |
|
| 225 | + | prefix := summary.Name + "-" + ref |
|
| 226 | + | switch format { |
|
| 227 | + | case "tar.gz": |
|
| 228 | + | w.Header().Set("Content-Type", "application/gzip") |
|
| 229 | + | w.Header().Set("Content-Disposition", `attachment; filename="`+summary.Name+"-"+ref+`.tar.gz"`) |
|
| 230 | + | if err := writeTarGz(w, repo, ref, prefix); err != nil { |
|
| 231 | + | a.Log.Error("tar.gz failed", "err", err) |
|
| 232 | + | } |
|
| 233 | + | case "zip": |
|
| 234 | + | w.Header().Set("Content-Type", "application/zip") |
|
| 235 | + | w.Header().Set("Content-Disposition", `attachment; filename="`+summary.Name+"-"+ref+`.zip"`) |
|
| 236 | + | if err := writeZip(w, repo, ref, prefix); err != nil { |
|
| 237 | + | a.Log.Error("zip failed", "err", err) |
|
| 238 | + | } |
|
| 239 | + | } |
|
| 240 | + | } |
|
| 241 | + | ||
| 242 | + | func (a *App) atomHandler(w http.ResponseWriter, r *http.Request) { |
|
| 243 | + | repo, summary, err := a.openRepo(r.PathValue("repo")) |
|
| 244 | + | if err != nil { |
|
| 245 | + | http.NotFound(w, r) |
|
| 246 | + | return |
|
| 247 | + | } |
|
| 248 | + | commits, _, err := commitLog(repo, summary.DefaultRef, 0, 20) |
|
| 249 | + | if err != nil { |
|
| 250 | + | http.Error(w, "log failed", http.StatusInternalServerError) |
|
| 251 | + | return |
|
| 252 | + | } |
|
| 253 | + | scheme := "http" |
|
| 254 | + | if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { |
|
| 255 | + | scheme = "https" |
|
| 256 | + | } |
|
| 257 | + | baseURL := scheme + "://" + r.Host |
|
| 258 | + | feed, err := buildAtomFeed(a.SiteName, summary.Name, baseURL, commits) |
|
| 259 | + | if err != nil { |
|
| 260 | + | http.Error(w, "atom build failed", http.StatusInternalServerError) |
|
| 261 | + | return |
|
| 262 | + | } |
|
| 263 | + | w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8") |
|
| 264 | + | _, _ = w.Write(feed) |
|
| 265 | + | } |
|
| 266 | + | ||
| 267 | + | func makeBreadcrumbs(repoName, ref, subPath string) []Breadcrumb { |
|
| 268 | + | out := []Breadcrumb{{Name: repoName, Href: "/r/" + repoName + "/tree/" + ref}} |
|
| 269 | + | if subPath == "" { |
|
| 270 | + | return out |
|
| 271 | + | } |
|
| 272 | + | parts := strings.Split(subPath, "/") |
|
| 273 | + | acc := "" |
|
| 274 | + | for _, p := range parts { |
|
| 275 | + | if acc == "" { |
|
| 276 | + | acc = p |
|
| 277 | + | } else { |
|
| 278 | + | acc = acc + "/" + p |
|
| 279 | + | } |
|
| 280 | + | out = append(out, Breadcrumb{Name: p, Href: "/r/" + repoName + "/tree/" + ref + "/" + acc}) |
|
| 281 | + | } |
|
| 282 | + | return out |
|
| 283 | + | } |
Binary file — no preview.
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "log" |
|
| 5 | + | "log/slog" |
|
| 6 | + | "net/http" |
|
| 7 | + | "os" |
|
| 8 | + | ||
| 9 | + | "github.com/stevedylandev/andromeda/pkg/config" |
|
| 10 | + | ) |
|
| 11 | + | ||
| 12 | + | func main() { |
|
| 13 | + | config.LoadDotEnv(".env") |
|
| 14 | + | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) |
|
| 15 | + | ||
| 16 | + | root := config.Getenv("KEPLER_REPO_ROOT", "./repos") |
|
| 17 | + | siteName := config.Getenv("KEPLER_SITE_NAME", "kepler") |
|
| 18 | + | ||
| 19 | + | tmpl, err := buildTemplates() |
|
| 20 | + | if err != nil { |
|
| 21 | + | log.Fatal(err) |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | app := &App{ |
|
| 25 | + | Log: logger, |
|
| 26 | + | Templates: tmpl, |
|
| 27 | + | RepoRoot: root, |
|
| 28 | + | SiteName: siteName, |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "4747") |
|
| 32 | + | logger.Info("kepler server running", "addr", addr, "repo_root", root) |
|
| 33 | + | if err := http.ListenAndServe(addr, app.routes()); err != nil { |
|
| 34 | + | log.Fatal(err) |
|
| 35 | + | } |
|
| 36 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "bytes" |
|
| 5 | + | "fmt" |
|
| 6 | + | "html/template" |
|
| 7 | + | "io/fs" |
|
| 8 | + | "net/http" |
|
| 9 | + | "path" |
|
| 10 | + | "strings" |
|
| 11 | + | "time" |
|
| 12 | + | ||
| 13 | + | "github.com/go-git/go-git/v5/plumbing/object" |
|
| 14 | + | "github.com/yuin/goldmark" |
|
| 15 | + | "github.com/yuin/goldmark/extension" |
|
| 16 | + | "github.com/yuin/goldmark/parser" |
|
| 17 | + | gmhtml "github.com/yuin/goldmark/renderer/html" |
|
| 18 | + | ||
| 19 | + | "github.com/stevedylandev/andromeda/pkg/web" |
|
| 20 | + | ) |
|
| 21 | + | ||
| 22 | + | var md = goldmark.New( |
|
| 23 | + | goldmark.WithExtensions(extension.Strikethrough, extension.Table, extension.TaskList, extension.Linkify), |
|
| 24 | + | goldmark.WithParserOptions(parser.WithAutoHeadingID()), |
|
| 25 | + | goldmark.WithRendererOptions(gmhtml.WithUnsafe()), |
|
| 26 | + | ) |
|
| 27 | + | ||
| 28 | + | func buildTemplates() (map[string]*template.Template, error) { |
|
| 29 | + | funcs := template.FuncMap{ |
|
| 30 | + | "shortSHA": func(s string) string { |
|
| 31 | + | if len(s) >= 8 { |
|
| 32 | + | return s[:8] |
|
| 33 | + | } |
|
| 34 | + | return s |
|
| 35 | + | }, |
|
| 36 | + | "humanSize": humanSize, |
|
| 37 | + | "timeAgo": timeAgo, |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | pages, err := fs.Glob(appFS, "templates/*.html") |
|
| 41 | + | if err != nil { |
|
| 42 | + | return nil, err |
|
| 43 | + | } |
|
| 44 | + | out := make(map[string]*template.Template, len(pages)) |
|
| 45 | + | for _, page := range pages { |
|
| 46 | + | if strings.HasSuffix(page, "/base.html") { |
|
| 47 | + | continue |
|
| 48 | + | } |
|
| 49 | + | tmpl, err := template.New("").Funcs(funcs).ParseFS(appFS, "templates/base.html", page) |
|
| 50 | + | if err != nil { |
|
| 51 | + | return nil, fmt.Errorf("parse %s: %w", page, err) |
|
| 52 | + | } |
|
| 53 | + | out[path.Base(page)] = tmpl |
|
| 54 | + | } |
|
| 55 | + | return out, nil |
|
| 56 | + | } |
|
| 57 | + | ||
| 58 | + | func (a *App) renderPage(w http.ResponseWriter, name string, data any) { |
|
| 59 | + | tmpl, ok := a.Templates[name] |
|
| 60 | + | if !ok { |
|
| 61 | + | a.Log.Error("template missing", "name", name) |
|
| 62 | + | http.Error(w, "template missing", http.StatusInternalServerError) |
|
| 63 | + | return |
|
| 64 | + | } |
|
| 65 | + | web.Render(tmpl, w, name, data, a.Log) |
|
| 66 | + | } |
|
| 67 | + | ||
| 68 | + | func renderMarkdown(source string) (template.HTML, error) { |
|
| 69 | + | var buf bytes.Buffer |
|
| 70 | + | if err := md.Convert([]byte(source), &buf); err != nil { |
|
| 71 | + | return "", err |
|
| 72 | + | } |
|
| 73 | + | return template.HTML(buf.String()), nil |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | func renderBlobLines(source string) template.HTML { |
|
| 77 | + | source = strings.ReplaceAll(source, "\r\n", "\n") |
|
| 78 | + | lines := strings.Split(source, "\n") |
|
| 79 | + | if len(lines) > 0 && lines[len(lines)-1] == "" { |
|
| 80 | + | lines = lines[:len(lines)-1] |
|
| 81 | + | } |
|
| 82 | + | var buf bytes.Buffer |
|
| 83 | + | buf.WriteString(`<table class="blob-code"><tbody>`) |
|
| 84 | + | for i, line := range lines { |
|
| 85 | + | n := i + 1 |
|
| 86 | + | fmt.Fprintf(&buf, |
|
| 87 | + | `<tr id="L%d"><td class="line-num"><a href="#L%d">%d</a></td><td class="line-code"><pre>%s</pre></td></tr>`, |
|
| 88 | + | n, n, n, template.HTMLEscapeString(line), |
|
| 89 | + | ) |
|
| 90 | + | } |
|
| 91 | + | buf.WriteString(`</tbody></table>`) |
|
| 92 | + | return template.HTML(buf.String()) |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | func findReadme(tree *object.Tree) (string, bool) { |
|
| 96 | + | names := []string{"README.md", "readme.md", "Readme.md", "README", "readme", "README.txt", "readme.txt"} |
|
| 97 | + | for _, n := range names { |
|
| 98 | + | f, err := tree.File(n) |
|
| 99 | + | if err == nil { |
|
| 100 | + | content, err := f.Contents() |
|
| 101 | + | if err == nil { |
|
| 102 | + | return content, true |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | return "", false |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | func convertPatch(patch *object.Patch) ([]FilePatch, DiffStats) { |
|
| 110 | + | var files []FilePatch |
|
| 111 | + | stats := DiffStats{} |
|
| 112 | + | for _, fp := range patch.FilePatches() { |
|
| 113 | + | from, to := fp.Files() |
|
| 114 | + | fromName, toName := "", "" |
|
| 115 | + | if from != nil { |
|
| 116 | + | fromName = from.Path() |
|
| 117 | + | } |
|
| 118 | + | if to != nil { |
|
| 119 | + | toName = to.Path() |
|
| 120 | + | } |
|
| 121 | + | out := FilePatch{From: fromName, To: toName, IsBin: fp.IsBinary()} |
|
| 122 | + | var all []DiffLine |
|
| 123 | + | oldNum, newNum := 1, 1 |
|
| 124 | + | for _, ch := range fp.Chunks() { |
|
| 125 | + | kind := "" |
|
| 126 | + | switch ch.Type() { |
|
| 127 | + | case 1: // Add |
|
| 128 | + | kind = "add" |
|
| 129 | + | case 2: // Delete |
|
| 130 | + | kind = "del" |
|
| 131 | + | default: |
|
| 132 | + | kind = "ctx" |
|
| 133 | + | } |
|
| 134 | + | text := ch.Content() |
|
| 135 | + | lines := strings.Split(text, "\n") |
|
| 136 | + | if len(lines) > 0 && lines[len(lines)-1] == "" { |
|
| 137 | + | lines = lines[:len(lines)-1] |
|
| 138 | + | } |
|
| 139 | + | for _, l := range lines { |
|
| 140 | + | dl := DiffLine{Kind: kind, Text: l} |
|
| 141 | + | switch kind { |
|
| 142 | + | case "add": |
|
| 143 | + | dl.NewNum = newNum |
|
| 144 | + | newNum++ |
|
| 145 | + | out.Added++ |
|
| 146 | + | case "del": |
|
| 147 | + | dl.OldNum = oldNum |
|
| 148 | + | oldNum++ |
|
| 149 | + | out.Removed++ |
|
| 150 | + | default: |
|
| 151 | + | dl.OldNum = oldNum |
|
| 152 | + | dl.NewNum = newNum |
|
| 153 | + | oldNum++ |
|
| 154 | + | newNum++ |
|
| 155 | + | } |
|
| 156 | + | all = append(all, dl) |
|
| 157 | + | } |
|
| 158 | + | } |
|
| 159 | + | out.Hunks = collapseContext(all, diffContextLines) |
|
| 160 | + | stats.Files++ |
|
| 161 | + | stats.Added += out.Added |
|
| 162 | + | stats.Removed += out.Removed |
|
| 163 | + | files = append(files, out) |
|
| 164 | + | } |
|
| 165 | + | return files, stats |
|
| 166 | + | } |
|
| 167 | + | ||
| 168 | + | const diffContextLines = 3 |
|
| 169 | + | ||
| 170 | + | func collapseContext(lines []DiffLine, ctx int) []DiffHunk { |
|
| 171 | + | if len(lines) == 0 { |
|
| 172 | + | return nil |
|
| 173 | + | } |
|
| 174 | + | keep := make([]bool, len(lines)) |
|
| 175 | + | for i, l := range lines { |
|
| 176 | + | if l.Kind == "add" || l.Kind == "del" { |
|
| 177 | + | start := i - ctx |
|
| 178 | + | if start < 0 { |
|
| 179 | + | start = 0 |
|
| 180 | + | } |
|
| 181 | + | end := i + ctx |
|
| 182 | + | if end >= len(lines) { |
|
| 183 | + | end = len(lines) - 1 |
|
| 184 | + | } |
|
| 185 | + | for j := start; j <= end; j++ { |
|
| 186 | + | keep[j] = true |
|
| 187 | + | } |
|
| 188 | + | } |
|
| 189 | + | } |
|
| 190 | + | var hunks []DiffHunk |
|
| 191 | + | var current *DiffHunk |
|
| 192 | + | for i, l := range lines { |
|
| 193 | + | if keep[i] { |
|
| 194 | + | if current == nil { |
|
| 195 | + | current = &DiffHunk{} |
|
| 196 | + | } |
|
| 197 | + | current.Lines = append(current.Lines, l) |
|
| 198 | + | } else if current != nil { |
|
| 199 | + | hunks = append(hunks, *current) |
|
| 200 | + | current = nil |
|
| 201 | + | } |
|
| 202 | + | } |
|
| 203 | + | if current != nil { |
|
| 204 | + | hunks = append(hunks, *current) |
|
| 205 | + | } |
|
| 206 | + | return hunks |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | func humanSize(n int64) string { |
|
| 210 | + | const k = 1024 |
|
| 211 | + | if n < k { |
|
| 212 | + | return fmt.Sprintf("%d B", n) |
|
| 213 | + | } |
|
| 214 | + | div, exp := int64(k), 0 |
|
| 215 | + | for n2 := n / k; n2 >= k; n2 /= k { |
|
| 216 | + | div *= k |
|
| 217 | + | exp++ |
|
| 218 | + | } |
|
| 219 | + | units := []string{"K", "M", "G", "T"} |
|
| 220 | + | return fmt.Sprintf("%.1f %s", float64(n)/float64(div), units[exp]) |
|
| 221 | + | } |
|
| 222 | + | ||
| 223 | + | func timeAgo(t time.Time) string { |
|
| 224 | + | if t.IsZero() { |
|
| 225 | + | return "" |
|
| 226 | + | } |
|
| 227 | + | d := time.Since(t) |
|
| 228 | + | switch { |
|
| 229 | + | case d < time.Minute: |
|
| 230 | + | return fmt.Sprintf("%ds ago", int(d.Seconds())) |
|
| 231 | + | case d < time.Hour: |
|
| 232 | + | return fmt.Sprintf("%dm ago", int(d.Minutes())) |
|
| 233 | + | case d < 24*time.Hour: |
|
| 234 | + | return fmt.Sprintf("%dh ago", int(d.Hours())) |
|
| 235 | + | case d < 30*24*time.Hour: |
|
| 236 | + | return fmt.Sprintf("%dd ago", int(d.Hours()/24)) |
|
| 237 | + | default: |
|
| 238 | + | return t.Format("2006-01-02") |
|
| 239 | + | } |
|
| 240 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "errors" |
|
| 5 | + | "os" |
|
| 6 | + | "path/filepath" |
|
| 7 | + | "sort" |
|
| 8 | + | "strings" |
|
| 9 | + | ||
| 10 | + | "github.com/go-git/go-git/v5" |
|
| 11 | + | "github.com/go-git/go-git/v5/plumbing" |
|
| 12 | + | ) |
|
| 13 | + | ||
| 14 | + | var defaultDescriptionPlaceholder = "Unnamed repository; edit this file 'description' to name the repository." |
|
| 15 | + | ||
| 16 | + | func (a *App) listRepos() ([]RepoSummary, error) { |
|
| 17 | + | entries, err := os.ReadDir(a.RepoRoot) |
|
| 18 | + | if err != nil { |
|
| 19 | + | return nil, err |
|
| 20 | + | } |
|
| 21 | + | var out []RepoSummary |
|
| 22 | + | for _, e := range entries { |
|
| 23 | + | if !e.IsDir() { |
|
| 24 | + | continue |
|
| 25 | + | } |
|
| 26 | + | path, name, ok := resolveRepoDir(a.RepoRoot, e.Name()) |
|
| 27 | + | if !ok { |
|
| 28 | + | continue |
|
| 29 | + | } |
|
| 30 | + | s := RepoSummary{Name: name, Description: readDescription(path)} |
|
| 31 | + | if repo, err := git.PlainOpen(path); err == nil { |
|
| 32 | + | s.DefaultRef = defaultBranchName(repo) |
|
| 33 | + | if head, err := repo.Head(); err == nil { |
|
| 34 | + | if c, err := repo.CommitObject(head.Hash()); err == nil { |
|
| 35 | + | s.LastCommit = c.Author.When |
|
| 36 | + | } |
|
| 37 | + | } |
|
| 38 | + | } |
|
| 39 | + | out = append(out, s) |
|
| 40 | + | } |
|
| 41 | + | sort.Slice(out, func(i, j int) bool { |
|
| 42 | + | if !out[i].LastCommit.Equal(out[j].LastCommit) { |
|
| 43 | + | return out[i].LastCommit.After(out[j].LastCommit) |
|
| 44 | + | } |
|
| 45 | + | return out[i].Name < out[j].Name |
|
| 46 | + | }) |
|
| 47 | + | return out, nil |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | func (a *App) openRepo(name string) (*git.Repository, RepoSummary, error) { |
|
| 51 | + | clean := strings.Trim(filepath.Clean(name), "/\\") |
|
| 52 | + | if clean == "" || strings.Contains(clean, "..") || strings.ContainsAny(clean, "/\\") { |
|
| 53 | + | return nil, RepoSummary{}, errors.New("invalid repo name") |
|
| 54 | + | } |
|
| 55 | + | path, resolvedName, ok := findRepoByName(a.RepoRoot, clean) |
|
| 56 | + | if !ok { |
|
| 57 | + | return nil, RepoSummary{}, errors.New("repo not found") |
|
| 58 | + | } |
|
| 59 | + | repo, err := git.PlainOpen(path) |
|
| 60 | + | if err != nil { |
|
| 61 | + | return nil, RepoSummary{}, err |
|
| 62 | + | } |
|
| 63 | + | s := RepoSummary{ |
|
| 64 | + | Name: resolvedName, |
|
| 65 | + | Description: readDescription(path), |
|
| 66 | + | DefaultRef: defaultBranchName(repo), |
|
| 67 | + | } |
|
| 68 | + | if head, err := repo.Head(); err == nil { |
|
| 69 | + | if c, err := repo.CommitObject(head.Hash()); err == nil { |
|
| 70 | + | s.LastCommit = c.Author.When |
|
| 71 | + | } |
|
| 72 | + | } |
|
| 73 | + | return repo, s, nil |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | func resolveRepoDir(root, entry string) (path, name string, ok bool) { |
|
| 77 | + | full := filepath.Join(root, entry) |
|
| 78 | + | if strings.HasSuffix(entry, ".git") { |
|
| 79 | + | if _, err := os.Stat(filepath.Join(full, "HEAD")); err == nil { |
|
| 80 | + | return full, strings.TrimSuffix(entry, ".git"), true |
|
| 81 | + | } |
|
| 82 | + | return "", "", false |
|
| 83 | + | } |
|
| 84 | + | if _, err := os.Stat(filepath.Join(full, ".git", "HEAD")); err == nil { |
|
| 85 | + | return full, entry, true |
|
| 86 | + | } |
|
| 87 | + | if _, err := os.Stat(filepath.Join(full, "HEAD")); err == nil { |
|
| 88 | + | return full, entry, true |
|
| 89 | + | } |
|
| 90 | + | return "", "", false |
|
| 91 | + | } |
|
| 92 | + | ||
| 93 | + | func findRepoByName(root, name string) (string, string, bool) { |
|
| 94 | + | candidates := []string{name + ".git", name} |
|
| 95 | + | for _, c := range candidates { |
|
| 96 | + | if p, n, ok := resolveRepoDir(root, c); ok { |
|
| 97 | + | return p, n, true |
|
| 98 | + | } |
|
| 99 | + | } |
|
| 100 | + | return "", "", false |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | func readDescription(repoPath string) string { |
|
| 104 | + | data, err := os.ReadFile(filepath.Join(repoPath, "description")) |
|
| 105 | + | if err != nil { |
|
| 106 | + | return "" |
|
| 107 | + | } |
|
| 108 | + | s := strings.TrimSpace(string(data)) |
|
| 109 | + | if s == defaultDescriptionPlaceholder { |
|
| 110 | + | return "" |
|
| 111 | + | } |
|
| 112 | + | return s |
|
| 113 | + | } |
|
| 114 | + | ||
| 115 | + | func defaultBranchName(repo *git.Repository) string { |
|
| 116 | + | head, err := repo.Reference(plumbing.HEAD, false) |
|
| 117 | + | if err != nil { |
|
| 118 | + | return "main" |
|
| 119 | + | } |
|
| 120 | + | if head.Type() == plumbing.SymbolicReference { |
|
| 121 | + | return head.Target().Short() |
|
| 122 | + | } |
|
| 123 | + | return "main" |
|
| 124 | + | } |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import ( |
|
| 4 | + | "net/http" |
|
| 5 | + | ) |
|
| 6 | + | ||
| 7 | + | func (a *App) routes() *http.ServeMux { |
|
| 8 | + | mux := http.NewServeMux() |
|
| 9 | + | ||
| 10 | + | mux.HandleFunc("GET /assets/kepler.css", func(w http.ResponseWriter, r *http.Request) { |
|
| 11 | + | data, err := appFS.ReadFile("static/styles.css") |
|
| 12 | + | if err != nil { |
|
| 13 | + | http.NotFound(w, r) |
|
| 14 | + | return |
|
| 15 | + | } |
|
| 16 | + | w.Header().Set("Content-Type", "text/css; charset=utf-8") |
|
| 17 | + | _, _ = w.Write(data) |
|
| 18 | + | }) |
|
| 19 | + | mux.HandleFunc("GET /assets/fonts/{name}", func(w http.ResponseWriter, r *http.Request) { |
|
| 20 | + | name := r.PathValue("name") |
|
| 21 | + | data, err := appFS.ReadFile("static/fonts/" + name) |
|
| 22 | + | if err != nil { |
|
| 23 | + | http.NotFound(w, r) |
|
| 24 | + | return |
|
| 25 | + | } |
|
| 26 | + | w.Header().Set("Content-Type", "font/otf") |
|
| 27 | + | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
|
| 28 | + | w.Header().Set("Access-Control-Allow-Origin", "*") |
|
| 29 | + | _, _ = w.Write(data) |
|
| 30 | + | }) |
|
| 31 | + | ||
| 32 | + | mux.HandleFunc("GET /{$}", a.indexHandler) |
|
| 33 | + | mux.HandleFunc("GET /r/{repo}", a.repoHandler) |
|
| 34 | + | mux.HandleFunc("GET /r/{repo}/refs", a.refsHandler) |
|
| 35 | + | mux.HandleFunc("GET /r/{repo}/atom.xml", a.atomHandler) |
|
| 36 | + | mux.HandleFunc("GET /r/{repo}/log/{ref}", a.logHandler) |
|
| 37 | + | mux.HandleFunc("GET /r/{repo}/commit/{sha}", a.commitHandler) |
|
| 38 | + | mux.HandleFunc("GET /r/{repo}/tree/{ref}", a.treeHandler) |
|
| 39 | + | mux.HandleFunc("GET /r/{repo}/tree/{ref}/{path...}", a.treeHandler) |
|
| 40 | + | mux.HandleFunc("GET /r/{repo}/blob/{ref}/{path...}", a.blobHandler) |
|
| 41 | + | mux.HandleFunc("GET /r/{repo}/raw/{ref}/{path...}", a.rawHandler) |
|
| 42 | + | mux.HandleFunc("GET /r/{repo}/archive/{name}", a.archiveHandler) |
|
| 43 | + | ||
| 44 | + | return mux |
|
| 45 | + | } |
Binary file — no preview.
Binary file — no preview.
| 1 | + | /* Adapted from darkmatter: colors, font, reset. No global table rules. */ |
|
| 2 | + | ||
| 3 | + | @font-face { |
|
| 4 | + | font-family: "Commit Mono"; |
|
| 5 | + | src: url("/assets/fonts/CommitMono-400-Regular.otf") format("opentype"); |
|
| 6 | + | font-weight: 400; |
|
| 7 | + | font-style: normal; |
|
| 8 | + | font-display: block; |
|
| 9 | + | } |
|
| 10 | + | @font-face { |
|
| 11 | + | font-family: "Commit Mono"; |
|
| 12 | + | src: url("/assets/fonts/CommitMono-700-Regular.otf") format("opentype"); |
|
| 13 | + | font-weight: 700; |
|
| 14 | + | font-style: normal; |
|
| 15 | + | font-display: block; |
|
| 16 | + | } |
|
| 17 | + | ||
| 18 | + | :root { |
|
| 19 | + | --bg: #121113; |
|
| 20 | + | --bg-alt: #1e1c1f; |
|
| 21 | + | --fg: #ffffff; |
|
| 22 | + | --fg-dim: #888; |
|
| 23 | + | --border: #333; |
|
| 24 | + | --link: #ffffff; |
|
| 25 | + | --add-bg: #14241a; |
|
| 26 | + | --add-fg: #7ee787; |
|
| 27 | + | --del-bg: #2a1818; |
|
| 28 | + | --del-fg: #ff8585; |
|
| 29 | + | --mono: "Commit Mono", ui-monospace, SFMono-Regular, Menlo, monospace; |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | *, *::before, *::after { |
|
| 33 | + | margin: 0; |
|
| 34 | + | padding: 0; |
|
| 35 | + | box-sizing: border-box; |
|
| 36 | + | font-family: var(--mono); |
|
| 37 | + | -webkit-tap-highlight-color: transparent; |
|
| 38 | + | } |
|
| 39 | + | * { scrollbar-width: none; -ms-overflow-style: none; } |
|
| 40 | + | ||
| 41 | + | html { background: var(--bg); color: var(--fg); font-size: 14px; line-height: 1.6; } |
|
| 42 | + | html::-webkit-scrollbar { display: none; } |
|
| 43 | + | ||
| 44 | + | body { min-height: 100vh; padding: 0; } |
|
| 45 | + | ||
| 46 | + | a { color: var(--link); text-decoration: none; touch-action: manipulation; } |
|
| 47 | + | a:hover { opacity: 0.7; } |
|
| 48 | + | ||
| 49 | + | .container { max-width: 1400px; margin: 0 auto; padding: 0 1.5rem 4rem; } |
|
| 50 | + | ||
| 51 | + | .dim { color: var(--fg-dim); } |
|
| 52 | + | .empty { color: var(--fg-dim); font-style: italic; padding: 0.5rem 0; } |
|
| 53 | + | ||
| 54 | + | /* --- Top nav --- */ |
|
| 55 | + | ||
| 56 | + | .repo-nav { |
|
| 57 | + | border-bottom: 1px solid var(--border); |
|
| 58 | + | margin: 1rem 0; |
|
| 59 | + | padding: 0.25rem 0 0; |
|
| 60 | + | display: flex; |
|
| 61 | + | align-items: flex-end; |
|
| 62 | + | gap: 0.5rem; |
|
| 63 | + | } |
|
| 64 | + | .repo-name { |
|
| 65 | + | display: flex; |
|
| 66 | + | align-items: baseline; |
|
| 67 | + | gap: 0.75rem; |
|
| 68 | + | font-weight: 700; |
|
| 69 | + | font-size: 16px; |
|
| 70 | + | margin-bottom: -1px; |
|
| 71 | + | padding: 0.5rem 0; |
|
| 72 | + | } |
|
| 73 | + | .repo-name a { color: var(--fg); } |
|
| 74 | + | .repo-name .sep { color: var(--fg-dim); font-weight: 400; } |
|
| 75 | + | ||
| 76 | + | .tab { |
|
| 77 | + | display: inline-block; |
|
| 78 | + | padding: 0.5rem 0.75rem; |
|
| 79 | + | color: var(--fg-dim); |
|
| 80 | + | border: 1px solid transparent; |
|
| 81 | + | border-bottom: none; |
|
| 82 | + | margin-bottom: -1px; |
|
| 83 | + | font-size: 13px; |
|
| 84 | + | } |
|
| 85 | + | .tab:hover { color: var(--fg); opacity: 1; } |
|
| 86 | + | .tab.active { |
|
| 87 | + | color: var(--fg); |
|
| 88 | + | border-color: var(--border); |
|
| 89 | + | border-radius: 4px 4px 0 0; |
|
| 90 | + | background: var(--bg); |
|
| 91 | + | } |
|
| 92 | + | .repo-nav .tab:first-of-type { margin-left: auto; } |
|
| 93 | + | ||
| 94 | + | main { display: block; } |
|
| 95 | + | ||
| 96 | + | h1 { font-weight: 700; font-size: 18px; margin-bottom: 0.75rem; } |
|
| 97 | + | h2 { font-weight: 700; font-size: 15px; margin-bottom: 0.5rem; } |
|
| 98 | + | h3 { font-weight: 700; font-size: 14px; margin: 1.25rem 0 0.5rem; } |
|
| 99 | + | h3:first-child { margin-top: 0; } |
|
| 100 | + | ||
| 101 | + | /* --- Generic list tables (repo/log/ref) --- */ |
|
| 102 | + | ||
| 103 | + | .repo-list, .log-list, .ref-list { |
|
| 104 | + | width: 100%; |
|
| 105 | + | border-collapse: collapse; |
|
| 106 | + | margin-bottom: 1rem; |
|
| 107 | + | font-size: 13px; |
|
| 108 | + | } |
|
| 109 | + | .repo-list th, .log-list th, .ref-list th { |
|
| 110 | + | text-align: left; |
|
| 111 | + | padding: 0.4rem 0.5rem; |
|
| 112 | + | color: var(--fg-dim); |
|
| 113 | + | font-weight: 400; |
|
| 114 | + | font-size: 12px; |
|
| 115 | + | text-transform: uppercase; |
|
| 116 | + | border-bottom: 1px solid var(--border); |
|
| 117 | + | } |
|
| 118 | + | .repo-list td, .log-list td, .ref-list td { |
|
| 119 | + | padding: 0.35rem 0.5rem; |
|
| 120 | + | vertical-align: top; |
|
| 121 | + | } |
|
| 122 | + | .repo-list tbody tr:hover, |
|
| 123 | + | .log-list tbody tr:hover, |
|
| 124 | + | .ref-list tbody tr:hover { background: var(--bg-alt); } |
|
| 125 | + | ||
| 126 | + | .hash { white-space: nowrap; } |
|
| 127 | + | .hash a { color: var(--fg-dim); } |
|
| 128 | + | .hash a:hover { color: var(--fg); opacity: 1; } |
|
| 129 | + | .date { white-space: nowrap; color: var(--fg-dim); } |
|
| 130 | + | .author { color: var(--fg-dim); white-space: nowrap; } |
|
| 131 | + | .subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|
| 132 | + | .size { color: var(--fg-dim); text-align: right; white-space: nowrap; font-size: 12px; } |
|
| 133 | + | ||
| 134 | + | @media (max-width: 720px) { .desktop { display: none; } } |
|
| 135 | + | ||
| 136 | + | /* --- Breadcrumbs --- */ |
|
| 137 | + | ||
| 138 | + | .breadcrumbs { font-size: 13px; margin-bottom: 1rem; color: var(--fg-dim); } |
|
| 139 | + | .breadcrumbs a { color: var(--fg); } |
|
| 140 | + | ||
| 141 | + | /* --- Last commit bar --- */ |
|
| 142 | + | ||
| 143 | + | .last-commit { |
|
| 144 | + | display: flex; |
|
| 145 | + | align-items: center; |
|
| 146 | + | gap: 0.75rem; |
|
| 147 | + | border: 1px solid var(--border); |
|
| 148 | + | border-radius: 4px; |
|
| 149 | + | padding: 0.4rem 0.75rem; |
|
| 150 | + | font-size: 13px; |
|
| 151 | + | overflow: hidden; |
|
| 152 | + | margin-bottom: 1rem; |
|
| 153 | + | } |
|
| 154 | + | .last-commit .subject { flex: 1; } |
|
| 155 | + | ||
| 156 | + | /* --- Repo home grid --- */ |
|
| 157 | + | ||
| 158 | + | .repo-home { display: grid; grid-template-columns: 280px 1fr; gap: 1.5rem; align-items: start; } |
|
| 159 | + | @media (max-width: 720px) { .repo-home { grid-template-columns: 1fr; } } |
|
| 160 | + | ||
| 161 | + | .content-view { min-width: 0; } |
|
| 162 | + | ||
| 163 | + | /* --- File tree --- */ |
|
| 164 | + | ||
| 165 | + | .file-tree { font-size: 13px; } |
|
| 166 | + | .tree-entry { |
|
| 167 | + | display: flex; |
|
| 168 | + | align-items: baseline; |
|
| 169 | + | padding: 0.25rem 0.5rem; |
|
| 170 | + | border-radius: 4px; |
|
| 171 | + | } |
|
| 172 | + | .tree-entry:hover { opacity: 1; background: var(--bg-alt); } |
|
| 173 | + | .file-tree .name { white-space: nowrap; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; } |
|
| 174 | + | .file-tree .size { white-space: nowrap; margin-left: 0.5rem; } |
|
| 175 | + | ||
| 176 | + | /* --- Tree page directory listing --- */ |
|
| 177 | + | ||
| 178 | + | .tree-list { width: 100%; border-collapse: collapse; font-size: 13px; } |
|
| 179 | + | .tree-list td { padding: 0.3rem 0.5rem; vertical-align: top; } |
|
| 180 | + | .tree-list tr:hover { background: var(--bg-alt); } |
|
| 181 | + | ||
| 182 | + | /* --- File / blob view --- */ |
|
| 183 | + | ||
| 184 | + | .file-view { border: 1px solid var(--border); border-radius: 4px; } |
|
| 185 | + | .file-header { |
|
| 186 | + | display: flex; |
|
| 187 | + | gap: 0.75rem; |
|
| 188 | + | align-items: baseline; |
|
| 189 | + | padding: 0.5rem 0.75rem; |
|
| 190 | + | border-bottom: 1px solid var(--border); |
|
| 191 | + | font-size: 13px; |
|
| 192 | + | } |
|
| 193 | + | .file-name { font-weight: 700; flex: 1; } |
|
| 194 | + | .file-meta { color: var(--fg-dim); font-size: 12px; } |
|
| 195 | + | ||
| 196 | + | .blob-view { overflow: auto hidden; } |
|
| 197 | + | .blob-code { margin: 0; border-collapse: collapse; border-spacing: 0; width: 100%; } |
|
| 198 | + | .blob-code td { padding: 0; vertical-align: top; line-height: 0; border: 0; } |
|
| 199 | + | .blob-code tr:first-child td { padding-top: 0.5rem; } |
|
| 200 | + | .blob-code tr:last-child td { padding-bottom: 0.75rem; } |
|
| 201 | + | .blob-code pre { |
|
| 202 | + | margin: 0; |
|
| 203 | + | font-family: var(--mono); |
|
| 204 | + | font-size: 13px; |
|
| 205 | + | line-height: 1.5; |
|
| 206 | + | white-space: pre; |
|
| 207 | + | display: inline; |
|
| 208 | + | } |
|
| 209 | + | .blob-code .line-num { |
|
| 210 | + | width: 1%; |
|
| 211 | + | min-width: 4rem; |
|
| 212 | + | padding: 0 0.75rem 0 0.5rem; |
|
| 213 | + | text-align: right; |
|
| 214 | + | user-select: none; |
|
| 215 | + | vertical-align: top; |
|
| 216 | + | } |
|
| 217 | + | .line-num a { |
|
| 218 | + | color: var(--fg-dim); |
|
| 219 | + | opacity: 0.5; |
|
| 220 | + | font-size: 13px; |
|
| 221 | + | line-height: 1.5; |
|
| 222 | + | display: inline-block; |
|
| 223 | + | } |
|
| 224 | + | .line-num a:hover { opacity: 1; } |
|
| 225 | + | .blob-code .line-code { width: 100%; padding-left: 1.5rem; } |
|
| 226 | + | tr:target .line-num, |
|
| 227 | + | tr:target .line-code { background: var(--bg-alt); } |
|
| 228 | + | ||
| 229 | + | /* --- Commit view --- */ |
|
| 230 | + | ||
| 231 | + | .commit-info { margin-bottom: 1.5rem; } |
|
| 232 | + | .commit-title { display: flex; align-items: baseline; gap: 1rem; margin-bottom: 0.75rem; } |
|
| 233 | + | .commit-title .subject { font-weight: 700; font-size: 16px; flex: 1; white-space: normal; overflow: visible; } |
|
| 234 | + | .commit-hash { color: var(--fg-dim); white-space: nowrap; font-size: 13px; } |
|
| 235 | + | .commit-info .body { |
|
| 236 | + | background: var(--bg-alt); |
|
| 237 | + | padding: 0.5rem 0.75rem; |
|
| 238 | + | border-radius: 4px; |
|
| 239 | + | font-size: 13px; |
|
| 240 | + | margin: 0 0 1rem 0; |
|
| 241 | + | white-space: pre-wrap; |
|
| 242 | + | } |
|
| 243 | + | .commit-subtitle { |
|
| 244 | + | display: flex; |
|
| 245 | + | align-items: baseline; |
|
| 246 | + | gap: 1rem; |
|
| 247 | + | font-size: 13px; |
|
| 248 | + | color: var(--fg-dim); |
|
| 249 | + | padding-top: 0.5rem; |
|
| 250 | + | border-top: 1px solid var(--border); |
|
| 251 | + | } |
|
| 252 | + | .commit-meta { flex: 1; } |
|
| 253 | + | .commit-stats { white-space: nowrap; } |
|
| 254 | + | ||
| 255 | + | .add { color: var(--add-fg); } |
|
| 256 | + | .del { color: var(--del-fg); } |
|
| 257 | + | ||
| 258 | + | /* --- Diff --- */ |
|
| 259 | + | ||
| 260 | + | .diff-file { border: 1px solid var(--border); border-radius: 4px; margin-bottom: 1rem; overflow-x: auto; } |
|
| 261 | + | .diff-header { |
|
| 262 | + | display: flex; |
|
| 263 | + | align-items: center; |
|
| 264 | + | gap: 0.5rem; |
|
| 265 | + | border-bottom: 1px solid var(--border); |
|
| 266 | + | padding: 0.5rem 0.75rem; |
|
| 267 | + | font-size: 13px; |
|
| 268 | + | background: var(--bg-alt); |
|
| 269 | + | } |
|
| 270 | + | .diff-header .name { flex: 1; } |
|
| 271 | + | .diff-header-stats { margin-left: auto; font-size: 13px; white-space: nowrap; color: var(--fg-dim); } |
|
| 272 | + | ||
| 273 | + | .diff-hunk { margin: 0; border: none; width: 100%; border-collapse: collapse; } |
|
| 274 | + | .diff-hunk td { border: none; padding: 0; vertical-align: top; } |
|
| 275 | + | .diff-hunk tr:first-child td { padding-top: 0.5rem; } |
|
| 276 | + | .diff-hunk tr:last-child td { padding-bottom: 0.5rem; } |
|
| 277 | + | .diff-hunk pre { |
|
| 278 | + | margin: 0; |
|
| 279 | + | font-family: var(--mono); |
|
| 280 | + | font-size: 13px; |
|
| 281 | + | line-height: 1.5; |
|
| 282 | + | white-space: pre; |
|
| 283 | + | padding: 0 0.75rem; |
|
| 284 | + | } |
|
| 285 | + | .diff-hunk td.diff-num { |
|
| 286 | + | width: 1%; |
|
| 287 | + | min-width: 2.5rem; |
|
| 288 | + | padding: 0 0.25rem 0 0.5rem; |
|
| 289 | + | text-align: right; |
|
| 290 | + | color: var(--fg-dim); |
|
| 291 | + | opacity: 0.6; |
|
| 292 | + | user-select: none; |
|
| 293 | + | font-size: 13px; |
|
| 294 | + | line-height: 1.5; |
|
| 295 | + | white-space: nowrap; |
|
| 296 | + | } |
|
| 297 | + | .diff-add .diff-num { color: var(--add-fg); opacity: 0.7; } |
|
| 298 | + | .diff-del .diff-num { color: var(--del-fg); opacity: 0.7; } |
|
| 299 | + | ||
| 300 | + | td.diff-marker { |
|
| 301 | + | width: 1%; |
|
| 302 | + | padding: 0 0.25rem 0 0.75rem; |
|
| 303 | + | font-size: 13px; |
|
| 304 | + | line-height: 1.5; |
|
| 305 | + | user-select: none; |
|
| 306 | + | white-space: pre; |
|
| 307 | + | } |
|
| 308 | + | .diff-add .diff-marker { color: var(--add-fg); opacity: 0.8; } |
|
| 309 | + | .diff-del .diff-marker { color: var(--del-fg); opacity: 0.8; } |
|
| 310 | + | .diff-context .diff-marker { color: transparent; } |
|
| 311 | + | .diff-add td { background: var(--add-bg); } |
|
| 312 | + | .diff-del td { background: var(--del-bg); } |
|
| 313 | + | .diff-sep { margin: 0.25rem 0; border-top: 1px dashed var(--border); } |
|
| 314 | + | .diff-sep-row td { padding: 0; } |
|
| 315 | + | ||
| 316 | + | /* --- Pagination --- */ |
|
| 317 | + | ||
| 318 | + | .pagination { display: flex; justify-content: center; padding: 0.75rem 0; font-size: 13px; gap: 0; } |
|
| 319 | + | .pagination a { |
|
| 320 | + | padding: 0.4rem 0.75rem; |
|
| 321 | + | border: 1px solid var(--border); |
|
| 322 | + | color: var(--fg-dim); |
|
| 323 | + | } |
|
| 324 | + | .pagination a + a { border-left: none; } |
|
| 325 | + | .pagination a:first-child { border-radius: 4px 0 0 4px; } |
|
| 326 | + | .pagination a:last-child { border-radius: 0 4px 4px 0; } |
|
| 327 | + | .pagination a:only-child { border-radius: 4px; } |
|
| 328 | + | .pagination a:hover { color: var(--fg); opacity: 1; } |
|
| 329 | + | ||
| 330 | + | /* --- Readme --- */ |
|
| 331 | + | ||
| 332 | + | .readme { font-size: 14px; line-height: 1.6; max-width: 900px; min-width: 0; overflow-wrap: break-word; } |
|
| 333 | + | .readme img, .readme video, .readme svg { max-width: 100%; height: auto; } |
|
| 334 | + | .readme h1, .readme h2, .readme h3, .readme h4 { margin-top: 1.25rem; } |
|
| 335 | + | .readme pre { |
|
| 336 | + | background: var(--bg-alt); |
|
| 337 | + | padding: 0.75rem; |
|
| 338 | + | overflow-x: auto; |
|
| 339 | + | font-size: 13px; |
|
| 340 | + | border-radius: 4px; |
|
| 341 | + | } |
|
| 342 | + | .readme code { background: var(--bg-alt); padding: 1px 4px; font-size: 13px; border-radius: 3px; } |
|
| 343 | + | .readme pre code { background: transparent; padding: 0; } |
|
| 344 | + | .readme blockquote { border-left: 2px solid var(--border); margin: 0.75rem 0; padding-left: 0.75rem; color: var(--fg-dim); } |
|
| 345 | + | .readme table { border-collapse: collapse; margin: 0.75rem 0; } |
|
| 346 | + | .readme th, .readme td { border: 1px solid var(--border); padding: 0.25rem 0.5rem; } |
|
| 347 | + | .readme a { color: var(--fg); text-decoration: underline; } |
|
| 348 | + | ||
| 349 | + | /* --- Error page --- */ |
|
| 350 | + | ||
| 351 | + | .error-page { text-align: center; padding: 4rem 1rem; } |
| 1 | + | {{define "404.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}not found — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "content"}} |
|
| 4 | + | <div class="error-page"> |
|
| 5 | + | <h1>Not found</h1> |
|
| 6 | + | <p class="empty">The requested resource does not exist.</p> |
|
| 7 | + | </div> |
|
| 8 | + | {{end}} |
| 1 | + | {{define "base.html"}}<!DOCTYPE html> |
|
| 2 | + | <html lang="en"> |
|
| 3 | + | <head> |
|
| 4 | + | <meta charset="UTF-8"> |
|
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
| 6 | + | <meta name="theme-color" content="#121113"> |
|
| 7 | + | <title>{{block "title" .}}{{.SiteName}}{{end}}</title> |
|
| 8 | + | <link rel="preload" href="/assets/fonts/CommitMono-400-Regular.otf" as="font" type="font/otf" crossorigin> |
|
| 9 | + | <link rel="preload" href="/assets/fonts/CommitMono-700-Regular.otf" as="font" type="font/otf" crossorigin> |
|
| 10 | + | <link rel="stylesheet" href="/assets/kepler.css"> |
|
| 11 | + | </head> |
|
| 12 | + | <body> |
|
| 13 | + | <div class="container"> |
|
| 14 | + | <nav class="repo-nav"> |
|
| 15 | + | <span class="repo-name"> |
|
| 16 | + | <a href="/">{{.SiteName}}</a> |
|
| 17 | + | {{if .RepoName}}<span class="sep">/</span><a href="/r/{{.RepoName}}">{{.RepoName}}</a>{{end}} |
|
| 18 | + | </span> |
|
| 19 | + | {{block "tabs" .}}{{end}} |
|
| 20 | + | </nav> |
|
| 21 | + | <main> |
|
| 22 | + | {{block "content" .}}{{end}} |
|
| 23 | + | </main> |
|
| 24 | + | </div> |
|
| 25 | + | </body> |
|
| 26 | + | </html>{{end}} |
| 1 | + | {{define "blob.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Path}} — {{.Repo.Name}} — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}} |
|
| 4 | + | <a href="/r/{{.Repo.Name}}" class="tab">home</a> |
|
| 5 | + | <a href="/r/{{.Repo.Name}}/log/{{.Ref}}" class="tab">log</a> |
|
| 6 | + | <a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | + | <nav class="breadcrumbs"> |
|
| 10 | + | {{range $i, $b := .Breadcrumbs}}{{if $i}} / {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}} |
|
| 11 | + | </nav> |
|
| 12 | + | ||
| 13 | + | <div class="file-view"> |
|
| 14 | + | <header class="file-header"> |
|
| 15 | + | <span class="file-name">{{.Path}}</span> |
|
| 16 | + | <span class="file-meta">{{humanSize .Size}}</span> |
|
| 17 | + | <a href="/r/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a> |
|
| 18 | + | </header> |
|
| 19 | + | {{if .Binary}} |
|
| 20 | + | <p class="empty">Binary file — <a href="/r/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download raw</a>.</p> |
|
| 21 | + | {{else}} |
|
| 22 | + | <div class="blob-view">{{.HighlightedHTML}}</div> |
|
| 23 | + | {{end}} |
|
| 24 | + | </div> |
|
| 25 | + | {{end}} |
| 1 | + | {{define "commit.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Commit.ShortSHA}} — {{.Repo.Name}} — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}} |
|
| 4 | + | <a href="/r/{{.Repo.Name}}" class="tab">home</a> |
|
| 5 | + | <a href="/r/{{.Repo.Name}}/log/{{.DefaultRef}}" class="tab active">log</a> |
|
| 6 | + | <a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | + | <article class="commit-info"> |
|
| 10 | + | <div class="commit-title"> |
|
| 11 | + | <span class="subject">{{.Commit.Subject}}</span> |
|
| 12 | + | <span class="commit-hash">{{.Commit.ShortSHA}}</span> |
|
| 13 | + | </div> |
|
| 14 | + | {{if .Commit.Body}}<pre class="body">{{.Commit.Body}}</pre>{{end}} |
|
| 15 | + | <div class="commit-subtitle"> |
|
| 16 | + | <span class="commit-meta">{{.Commit.Author}} · {{.Commit.When.Format "2006-01-02 15:04"}}</span> |
|
| 17 | + | <span class="commit-stats">{{.Stats.Files}} file(s) · <span class="add">+{{.Stats.Added}}</span> <span class="del">−{{.Stats.Removed}}</span></span> |
|
| 18 | + | </div> |
|
| 19 | + | </article> |
|
| 20 | + | ||
| 21 | + | {{range .Files}} |
|
| 22 | + | <section class="diff-file"> |
|
| 23 | + | <header class="diff-header"> |
|
| 24 | + | <span class="name">{{if .From}}{{.From}}{{end}}{{if and .From .To (ne .From .To)}} → {{end}}{{if and .To (ne .From .To)}}{{.To}}{{end}}{{if and .From (not .To)}} (deleted){{end}}{{if and .To (not .From)}} (added){{end}}</span> |
|
| 25 | + | <span class="diff-header-stats"><span class="add">+{{.Added}}</span> <span class="del">−{{.Removed}}</span></span> |
|
| 26 | + | </header> |
|
| 27 | + | {{if .IsBin}} |
|
| 28 | + | <p class="empty">Binary file — no preview.</p> |
|
| 29 | + | {{else}} |
|
| 30 | + | <table class="diff-hunk"> |
|
| 31 | + | <tbody> |
|
| 32 | + | {{range $hi, $h := .Hunks}} |
|
| 33 | + | {{if $hi}}<tr class="diff-sep-row"><td colspan="4"><div class="diff-sep"></div></td></tr>{{end}} |
|
| 34 | + | {{range $h.Lines}} |
|
| 35 | + | <tr class="diff-{{if eq .Kind "ctx"}}context{{else}}{{.Kind}}{{end}}"> |
|
| 36 | + | <td class="diff-num">{{if .OldNum}}{{.OldNum}}{{end}}</td> |
|
| 37 | + | <td class="diff-num">{{if .NewNum}}{{.NewNum}}{{end}}</td> |
|
| 38 | + | <td class="diff-marker">{{if eq .Kind "add"}}+{{else if eq .Kind "del"}}-{{else}} {{end}}</td> |
|
| 39 | + | <td class="diff-code"><pre>{{.Text}}</pre></td> |
|
| 40 | + | </tr> |
|
| 41 | + | {{end}} |
|
| 42 | + | {{end}} |
|
| 43 | + | </tbody> |
|
| 44 | + | </table> |
|
| 45 | + | {{end}} |
|
| 46 | + | </section> |
|
| 47 | + | {{end}} |
|
| 48 | + | {{end}} |
| 1 | + | {{define "index.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}}<a href="/" class="tab active">repositories</a>{{end}} |
|
| 4 | + | {{define "content"}} |
|
| 5 | + | {{if .Repos}} |
|
| 6 | + | <table class="repo-list"> |
|
| 7 | + | <thead> |
|
| 8 | + | <tr> |
|
| 9 | + | <th>Repository</th> |
|
| 10 | + | </tr> |
|
| 11 | + | </thead> |
|
| 12 | + | <tbody> |
|
| 13 | + | {{range .Repos}} |
|
| 14 | + | <tr> |
|
| 15 | + | <td><a href="/r/{{.Name}}">{{.Name}}</a></td> |
|
| 16 | + | </tr> |
|
| 17 | + | {{end}} |
|
| 18 | + | </tbody> |
|
| 19 | + | </table> |
|
| 20 | + | {{else}} |
|
| 21 | + | <p class="empty">No repositories found.</p> |
|
| 22 | + | {{end}} |
|
| 23 | + | {{end}} |
| 1 | + | {{define "log.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}log — {{.Repo.Name}} — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}} |
|
| 4 | + | <a href="/r/{{.Repo.Name}}" class="tab">home</a> |
|
| 5 | + | <a href="/r/{{.Repo.Name}}/log/{{.Ref}}" class="tab active">log</a> |
|
| 6 | + | <a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | + | <table class="log-list"> |
|
| 10 | + | <thead> |
|
| 11 | + | <tr> |
|
| 12 | + | <th>Hash</th> |
|
| 13 | + | <th>Subject</th> |
|
| 14 | + | <th class="author desktop">Author</th> |
|
| 15 | + | <th>Age</th> |
|
| 16 | + | </tr> |
|
| 17 | + | </thead> |
|
| 18 | + | <tbody> |
|
| 19 | + | {{range .Commits}} |
|
| 20 | + | <tr> |
|
| 21 | + | <td class="hash"><a href="/r/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></td> |
|
| 22 | + | <td class="subject">{{.Subject}}</td> |
|
| 23 | + | <td class="author desktop">{{.Author}}</td> |
|
| 24 | + | <td class="date">{{timeAgo .When}}</td> |
|
| 25 | + | </tr> |
|
| 26 | + | {{end}} |
|
| 27 | + | </tbody> |
|
| 28 | + | </table> |
|
| 29 | + | ||
| 30 | + | <div class="pagination"> |
|
| 31 | + | {{if .HasPrev}}<a href="/r/{{.Repo.Name}}/log/{{.Ref}}?page={{.PrevPage}}">← Newer</a>{{end}} |
|
| 32 | + | {{if .HasNext}}<a href="/r/{{.Repo.Name}}/log/{{.Ref}}?page={{.NextPage}}">Older →</a>{{end}} |
|
| 33 | + | </div> |
|
| 34 | + | {{end}} |
| 1 | + | {{define "refs.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}refs — {{.Repo.Name}} — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}} |
|
| 4 | + | <a href="/r/{{.Repo.Name}}" class="tab">home</a> |
|
| 5 | + | <a href="/r/{{.Repo.Name}}/log/{{.DefaultRef}}" class="tab">log</a> |
|
| 6 | + | <a href="/r/{{.Repo.Name}}/refs" class="tab active">refs</a> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | + | <h3>Branches</h3> |
|
| 10 | + | {{if .Branches}} |
|
| 11 | + | <table class="ref-list"> |
|
| 12 | + | <tbody> |
|
| 13 | + | {{range .Branches}} |
|
| 14 | + | <tr> |
|
| 15 | + | <td><a href="/r/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a></td> |
|
| 16 | + | <td class="hash">{{shortSHA .SHA}}</td> |
|
| 17 | + | <td class="author">{{.Author}}</td> |
|
| 18 | + | <td class="date">{{timeAgo .Time}}</td> |
|
| 19 | + | <td><a href="/r/{{$.Repo.Name}}/log/{{.Name}}">log</a></td> |
|
| 20 | + | </tr> |
|
| 21 | + | {{end}} |
|
| 22 | + | </tbody> |
|
| 23 | + | </table> |
|
| 24 | + | {{else}} |
|
| 25 | + | <p class="empty">No branches.</p> |
|
| 26 | + | {{end}} |
|
| 27 | + | ||
| 28 | + | <h3>Tags</h3> |
|
| 29 | + | {{if .Tags}} |
|
| 30 | + | <table class="ref-list"> |
|
| 31 | + | <tbody> |
|
| 32 | + | {{range .Tags}} |
|
| 33 | + | <tr> |
|
| 34 | + | <td><a href="/r/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a></td> |
|
| 35 | + | <td class="hash">{{shortSHA .SHA}}</td> |
|
| 36 | + | <td class="author">{{.Author}}</td> |
|
| 37 | + | <td class="date">{{timeAgo .Time}}</td> |
|
| 38 | + | <td><a href="/r/{{$.Repo.Name}}/archive/{{.Name}}.tar.gz">tar.gz</a></td> |
|
| 39 | + | </tr> |
|
| 40 | + | {{end}} |
|
| 41 | + | </tbody> |
|
| 42 | + | </table> |
|
| 43 | + | {{else}} |
|
| 44 | + | <p class="empty">No tags.</p> |
|
| 45 | + | {{end}} |
|
| 46 | + | {{end}} |
| 1 | + | {{define "repo.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Repo.Name}} — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}} |
|
| 4 | + | <a href="/r/{{.Repo.Name}}" class="tab active">home</a> |
|
| 5 | + | <a href="/r/{{.Repo.Name}}/log/{{.DefaultRef}}" class="tab">log</a> |
|
| 6 | + | <a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | + | {{if .HasLatest}} |
|
| 10 | + | <div class="last-commit"> |
|
| 11 | + | <span class="hash"><a href="/r/{{.Repo.Name}}/commit/{{.LatestCommit.SHA}}">{{.LatestCommit.ShortSHA}}</a></span> |
|
| 12 | + | <span class="subject">{{.LatestCommit.Subject}}</span> |
|
| 13 | + | <span class="author">{{.LatestCommit.Author}}</span> |
|
| 14 | + | <span class="date">{{timeAgo .LatestCommit.When}}</span> |
|
| 15 | + | </div> |
|
| 16 | + | {{end}} |
|
| 17 | + | ||
| 18 | + | <div class="repo-home"> |
|
| 19 | + | <aside class="file-tree"> |
|
| 20 | + | {{if .Entries}} |
|
| 21 | + | {{range .Entries}} |
|
| 22 | + | {{if .IsDir}} |
|
| 23 | + | <a class="tree-entry" href="/r/{{$.Repo.Name}}/tree/{{$.DefaultRef}}/{{.Path}}"> |
|
| 24 | + | <span class="name">{{.Name}}/</span> |
|
| 25 | + | </a> |
|
| 26 | + | {{else}} |
|
| 27 | + | <a class="tree-entry" href="/r/{{$.Repo.Name}}/blob/{{$.DefaultRef}}/{{.Path}}"> |
|
| 28 | + | <span class="name">{{.Name}}</span> |
|
| 29 | + | <span class="size">{{humanSize .Size}}</span> |
|
| 30 | + | </a> |
|
| 31 | + | {{end}} |
|
| 32 | + | {{end}} |
|
| 33 | + | {{else}} |
|
| 34 | + | <p class="empty">Empty repository.</p> |
|
| 35 | + | {{end}} |
|
| 36 | + | </aside> |
|
| 37 | + | ||
| 38 | + | <section class="content-view"> |
|
| 39 | + | {{if .HasReadme}} |
|
| 40 | + | <article class="readme">{{.ReadmeHTML}}</article> |
|
| 41 | + | {{else}} |
|
| 42 | + | <p class="empty">No README.</p> |
|
| 43 | + | {{end}} |
|
| 44 | + | </section> |
|
| 45 | + | </div> |
|
| 46 | + | {{end}} |
| 1 | + | {{define "tree.html"}}{{template "base.html" .}}{{end}} |
|
| 2 | + | {{define "title"}}{{.Repo.Name}} / {{.Path}} — {{.SiteName}}{{end}} |
|
| 3 | + | {{define "tabs"}} |
|
| 4 | + | <a href="/r/{{.Repo.Name}}" class="tab">home</a> |
|
| 5 | + | <a href="/r/{{.Repo.Name}}/log/{{.Ref}}" class="tab">log</a> |
|
| 6 | + | <a href="/r/{{.Repo.Name}}/refs" class="tab">refs</a> |
|
| 7 | + | {{end}} |
|
| 8 | + | {{define "content"}} |
|
| 9 | + | <nav class="breadcrumbs"> |
|
| 10 | + | {{range $i, $b := .Breadcrumbs}}{{if $i}} / {{end}}<a href="{{$b.Href}}">{{$b.Name}}</a>{{end}} |
|
| 11 | + | </nav> |
|
| 12 | + | ||
| 13 | + | <table class="tree-list"> |
|
| 14 | + | <tbody> |
|
| 15 | + | {{range .Entries}} |
|
| 16 | + | <tr> |
|
| 17 | + | <td> |
|
| 18 | + | {{if .IsDir}} |
|
| 19 | + | <a href="/r/{{$.Repo.Name}}/tree/{{$.Ref}}/{{.Path}}">{{.Name}}/</a> |
|
| 20 | + | {{else}} |
|
| 21 | + | <a href="/r/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}">{{.Name}}</a> |
|
| 22 | + | {{end}} |
|
| 23 | + | </td> |
|
| 24 | + | <td class="size">{{if not .IsDir}}{{humanSize .Size}}{{end}}</td> |
|
| 25 | + | </tr> |
|
| 26 | + | {{end}} |
|
| 27 | + | </tbody> |
|
| 28 | + | </table> |
|
| 29 | + | {{end}} |
| 1 | + | package main |
|
| 2 | + | ||
| 3 | + | import "testing" |
|
| 4 | + | ||
| 5 | + | func TestBuildTemplates(t *testing.T) { |
|
| 6 | + | tmpls, err := buildTemplates() |
|
| 7 | + | if err != nil { |
|
| 8 | + | t.Fatalf("buildTemplates: %v", err) |
|
| 9 | + | } |
|
| 10 | + | for _, name := range []string{"index.html", "repo.html", "tree.html", "blob.html", "log.html", "commit.html", "refs.html", "404.html"} { |
|
| 11 | + | if _, ok := tmpls[name]; !ok { |
|
| 12 | + | t.Errorf("missing template: %s", name) |
|
| 13 | + | } |
|
| 14 | + | } |
|
| 15 | + | } |
|
| 16 | + | ||
| 17 | + | func TestParseArchiveName(t *testing.T) { |
|
| 18 | + | cases := []struct { |
|
| 19 | + | in string |
|
| 20 | + | ref string |
|
| 21 | + | format string |
|
| 22 | + | ok bool |
|
| 23 | + | }{ |
|
| 24 | + | {"main.tar.gz", "main", "tar.gz", true}, |
|
| 25 | + | {"v1.0.0.zip", "v1.0.0", "zip", true}, |
|
| 26 | + | {"abc.tgz", "abc", "tar.gz", true}, |
|
| 27 | + | {"main", "", "", false}, |
|
| 28 | + | } |
|
| 29 | + | for _, c := range cases { |
|
| 30 | + | ref, format, ok := parseArchiveName(c.in) |
|
| 31 | + | if ok != c.ok || ref != c.ref || format != c.format { |
|
| 32 | + | t.Errorf("%q → (%q, %q, %v), want (%q, %q, %v)", c.in, ref, format, ok, c.ref, c.format, c.ok) |
|
| 33 | + | } |
|
| 34 | + | } |
|
| 35 | + | } |
| 105 | 105 | - blobs_data:/data |
|
| 106 | 106 | env_file: apps/blobs/.env |
|
| 107 | 107 | ||
| 108 | + | kepler: |
|
| 109 | + | image: ghcr.io/stevedylandev/andromeda/kepler:latest |
|
| 110 | + | restart: unless-stopped |
|
| 111 | + | ports: |
|
| 112 | + | - "4747:4747" |
|
| 113 | + | volumes: |
|
| 114 | + | - kepler_repos:/data/repos:ro |
|
| 115 | + | env_file: apps/kepler/.env |
|
| 116 | + | ||
| 108 | 117 | backup: |
|
| 109 | 118 | image: ghcr.io/stevedylandev/andromeda/backup:latest |
|
| 110 | 119 | volumes: |
|
| 151 | 160 | blobs_data: |
|
| 152 | 161 | external: true |
|
| 153 | 162 | name: blobs_blobs-data |
|
| 163 | + | kepler_repos: |
|
| 164 | + | external: true |
|
| 165 | + | name: kepler_repos |
|