Initial commit

ae82ac81ffde875bc12a92ba2b32a96231077031
Alexis Sellier committed ago
.gitignore added +2 -0
1 +
.claude
2 +
/bin
.gitsigners added +1 -0
1 +
alexis ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpDRmIwBm4ajzW+METm9tBdK4CG2/v0qmO4bPfi+s+c alexis@radiant.computer
AGENTS.md added +165 -0
1 +
# Radiant Forge
2 +
3 +
A self-hosted Git repository browser. Single Go binary, static HTML, no JavaScript.
4 +
Uses the `git` CLI for all Git operations. All assets (CSS, fonts, JS, SVG logo,
5 +
templates) are embedded via `//go:embed`.
6 +
7 +
## Build & Run
8 +
9 +
    go build .
10 +
    ./forge -scan-path /srv/git -listen :8080
11 +
12 +
Flags: `-listen`, `-scan-path`, `-title`, `-base-url`, `-non-bare`,
13 +
`-username`, `-password`.
14 +
15 +
## Project Layout
16 +
17 +
```
18 +
main.go          Server init, repo scanning, HTTP mux, basic auth
19 +
handler.go       All HTTP handlers + route dispatcher
20 +
git.go           Types, diff collapsing, MIME map, tree sorting
21 +
git_cli.go       Git CLI backend (all git operations shell out to `git`)
22 +
template.go      Template loading, embed directives, template FuncMap
23 +
go.mod           Module: forge
24 +
static/
25 +
  style.css      Single stylesheet, embedded at compile time
26 +
  radiant.svg    Logo, embedded
27 +
  fonts/         5 TTF files (RethinkSans, IBM Plex Mono), embedded
28 +
  js/            Syntax highlighting (hirad.js, hiril.js), embedded
29 +
templates/
30 +
  layout.html    Base layout (header, nav, footer, wraps all pages)
31 +
  index.html     Repository list page
32 +
  home.html      Repo home: branch selector + file tree + content viewer
33 +
  log.html       Paginated commit log
34 +
  commit.html    Commit detail with unified diff
35 +
  refs.html      Branches and tags tables
36 +
  error.html     Error page
37 +
```
38 +
39 +
## Architecture
40 +
41 +
### Server struct (`main.go`)
42 +
43 +
`server` holds: `repos map[string]*RepoInfo`, `sorted []string` (by last update),
44 +
`tmpl *templateSet`, `title`, `baseURL`, `scanPath`, `username`, `password`.
45 +
46 +
Startup: parse flags -> `scanRepositories()` -> `loadTemplates()` -> `http.ListenAndServe`.
47 +
48 +
Repository scanning checks top-level dirs in `scan-path` for bare repos
49 +
(`HEAD` + `objects/` + `refs/`) or non-bare repos (`.git` subdir, opt-in via `-non-bare`).
50 +
Repos are private by default; they are only served if a `public` file exists in the
51 +
git directory. Reads optional `description` and `owner` files from the git directory.
52 +
53 +
Optional HTTP basic auth via `-username` and `-password` (both must be set together).
54 +
55 +
### Routing (`handler.go`)
56 +
57 +
`route()` is the main dispatcher. URL structure:
58 +
59 +
```
60 +
/                          -> handleIndex       (repo list)
61 +
/:repo/                    -> handleSummary     (repo home)
62 +
/:repo/refs                -> handleRefs        (branches + tags)
63 +
/:repo/log/:ref?page=N    -> handleLog         (paginated commit log)
64 +
/:repo/tree/:ref/path...  -> handleTree        (file/dir browser)
65 +
/:repo/commit/:hash       -> handleCommit      (commit detail + diff)
66 +
/:repo/raw/:ref/path...   -> handleRaw         (raw file download)
67 +
/style.css                 -> serveCSS
68 +
/radiant.svg               -> serveLogo
69 +
/js/*                      -> serveJS
70 +
/fonts/*                   -> serveFont
71 +
```
72 +
73 +
Static assets are registered on the mux directly; everything else goes through `route()`.
74 +
75 +
### Template data flow
76 +
77 +
All pages receive a `pageData` struct:
78 +
- `SiteTitle`, `BaseURL`, `Repo`, `Description`, `Section`, `Ref`, `CommitHash`
79 +
- `Data any` — page-specific data struct (e.g. `homeData`, `logData`, etc.)
80 +
81 +
Each page template defines `{{define "content"}}` which `layout.html` renders via
82 +
`{{template "content" .}}`.
83 +
84 +
Templates are loaded once at startup. Each page template is parsed together with
85 +
`layout.html` into its own `*template.Template`. Rendered via `templateSet.render()`.
86 +
87 +
### Template functions (`template.go` FuncMap)
88 +
89 +
`shortHash`, `timeAgo`, `formatDate`, `add`, `diffFileName`, `statusLabel`,
90 +
`formatSize`, `diffBar`, `langClass`, `parentPath`, `indent`, `autolink`.
91 +
92 +
`timeAgo`, `diffBar`, and `autolink` return raw `template.HTML`.
93 +
`indent` returns a CSS `padding-left` style string based on tree depth.
94 +
`autolink` HTML-escapes text and wraps `http://`/`https://` URLs in `<a>` tags.
95 +
96 +
### Repo home page (`handleSummary` / `handleTree` -> `renderHome`)
97 +
98 +
`renderHome(w, repo, ref, blob, activePath)` is the shared renderer for both the
99 +
summary page and the tree/file browser.
100 +
101 +
- If `ref` is empty, defaults to `getDefaultBranch()` (HEAD's branch name, or first
102 +
  available branch, or "main")
103 +
- Resolves the ref to a commit hash
104 +
- Builds the file tree via `buildTreeNodes()` — returns a flat `[]TreeNode` list with
105 +
  depth markers, expanding only the directories on the active path
106 +
- Fetches branches via `getBranches()` for the branch selector dropdown
107 +
- Gets README from the active directory (or root)
108 +
- Template data struct: `homeData` with fields `DefaultRef`, `Branches`,
109 +
  `Tree`, `Readme`, `LastCommit`, `ActiveBlob`, `ActivePath`, `IsEmpty`
110 +
111 +
The branch selector is a pure-HTML `<details>/<summary>` dropdown (no JS).
112 +
113 +
### Git operations (`git_cli.go`)
114 +
115 +
All git operations shell out to the `git` CLI via `exec.Command`. This supports
116 +
SHA256 repositories which `go-git` cannot handle.
117 +
118 +
Key functions:
119 +
- `resolveRef(refStr)` — resolve ref name or HEAD to a commit hash
120 +
- `resolveRefAndPath(segments)` — progressively try longer segment prefixes as
121 +
  ref names (handles refs with slashes like `release/v1.0`)
122 +
- `getDefaultBranch()` — HEAD's branch name, fallback to first branch, then "main"
123 +
- `getBranches()` / `getTags()` — returns `[]RefInfo` sorted by date descending
124 +
- `getTree(hash, path)` — list directory entries, sorted dirs-first then alphabetical
125 +
- `buildTreeNodes(hash, activePath)` — recursive flat tree for template rendering
126 +
- `getBlob(hash, path)` — file content, up to 1MB, with binary/UTF-8 detection
127 +
- `getRawBlob(hash, path)` — raw `io.ReadCloser` for downloads
128 +
- `getLog(hash, page, perPage)` — paginated commit list
129 +
- `getDiff(hash)` — unified diff with context collapsing (`diffContextLines = 5`)
130 +
- `getCommit(hash)` — single commit info
131 +
- `getReadme(hash, dir)` — finds readme/README.md/README.txt in a directory
132 +
- `isTreePath(hash, path)` — checks if a path is a directory
133 +
134 +
Key types (`git.go`): `RepoInfo`, `CommitInfo`, `TreeNode`, `BlobInfo`, `RefInfo`,
135 +
`DiffFile`, `DiffHunk`, `DiffLine`, `TreeEntryInfo`, `DiffStats`.
136 +
137 +
### CSS (`static/style.css`)
138 +
139 +
CSS custom properties in `:root` for colors and fonts. Two font families:
140 +
`RethinkSans` (sans-serif, body text) and `IBM Plex Mono` (monospace, code/hashes).
141 +
142 +
Light theme: beige background (`#d3d1d1`), dark text (`#112`), blue links (`#223377`).
143 +
Diff colors: green adds (`#c8e6c9`/`#2e7d32`), red deletes (`#ffcdd2`/`#c62828`).
144 +
145 +
Font sizes are limited to three values: `0.875rem` (small), `1rem` (base), and
146 +
`1.125rem` (large). Do not introduce other font sizes.
147 +
148 +
Margins and padding use `0.25rem` increments (e.g. `0.25rem`, `0.5rem`, `0.75rem`,
149 +
`1rem`, `1.5rem`, `2rem`). Do not use arbitrary values like `0.3rem` or `0.4rem`.
150 +
151 +
No media queries, no dark mode, no responsive breakpoints.
152 +
153 +
## Conventions
154 +
155 +
- No JavaScript anywhere. All interactivity is pure HTML (links, `<details>`).
156 +
  The only JS files are syntax highlighting scripts served as static assets.
157 +
- All assets are embedded — the binary is fully self-contained.
158 +
- Templates use Go's `html/template` with a shared layout pattern.
159 +
- Git operations shell out to the `git` CLI (no `go-git` dependency).
160 +
- Errors in git operations generally return `nil`/empty rather than propagating to the user
161 +
  (e.g. `getBranches` errors are silently ignored).
162 +
- Handler functions follow the pattern: build page-specific data struct, wrap in `pageData`,
163 +
  call `s.tmpl.render()`.
164 +
- CSS uses a flat structure with class-based selectors, no BEM or similar methodology.
165 +
- Repos must have a `public` file in the git directory to be listed.
README.md added +57 -0
1 +
# source-browser
2 +
3 +
A self-hosted git repository browser, similar to CGit. Single Go binary,
4 +
static HTML, no JavaScript.
5 +
6 +
## Build
7 +
8 +
    go build .
9 +
10 +
## Usage
11 +
12 +
    ./forge [flags]
13 +
14 +
Flags:
15 +
16 +
    -scan-path <path>    Directory containing bare git repos (default ".")
17 +
    -listen <addr>       Listen address (default ":8080")
18 +
    -title <string>      Site title (default "radiant code repositories")
19 +
    -base-url <string>   Base URL prefix, e.g. "/git" (default "")
20 +
    -non-bare            Also scan for non-bare repos (dirs containing .git)
21 +
    -username <string>   HTTP basic auth username (requires -password)
22 +
    -password <string>   HTTP basic auth password (requires -username)
23 +
24 +
The server scans `scan-path` for bare repositories (directories containing
25 +
`HEAD`, `objects/`, and `refs/`). Repo names are derived from directory
26 +
names with the `.git` suffix stripped.
27 +
28 +
Repositories are **private by default**. A repo is only served if a `public`
29 +
file exists in its Git directory.
30 +
31 +
## Example
32 +
33 +
Set up some bare repos and start the server:
34 +
35 +
    git init --bare /srv/git/example.git
36 +
    touch /srv/git/example.git/public
37 +
    ./forge -scan-path /srv/git -listen :8080
38 +
39 +
Then open http://localhost:8080 in a browser.
40 +
41 +
## URL scheme
42 +
43 +
    /                              Repository index
44 +
    /:repo/                        Summary (file tree + README)
45 +
    /:repo/refs                    All branches and tags
46 +
    /:repo/log/:ref                Commit log (paginated, default: HEAD)
47 +
    /:repo/tree/:ref/path...       Tree/file browser
48 +
    /:repo/commit/:hash            Commit detail with diff
49 +
    /:repo/raw/:ref/path...        Raw file download
50 +
51 +
## Repository metadata
52 +
53 +
The server reads optional metadata files from each bare repo:
54 +
55 +
- `description` -- shown on the index and summary pages
56 +
- `owner` -- shown on the index page
57 +
- `public` -- must exist for the repo to be served (can be empty)
git.go added +724 -0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"io"
6 +
	"sort"
7 +
	"strings"
8 +
	"time"
9 +
	"unicode/utf8"
10 +
11 +
	git "github.com/go-git/go-git/v5"
12 +
	"github.com/go-git/go-git/v5/plumbing"
13 +
	"github.com/go-git/go-git/v5/plumbing/object"
14 +
)
15 +
16 +
type RepoInfo struct {
17 +
	Name        string
18 +
	Path        string
19 +
	Description string
20 +
	Owner       string
21 +
	LastUpdated time.Time
22 +
	Repo        *git.Repository
23 +
}
24 +
25 +
type CommitInfo struct {
26 +
	Hash        string
27 +
	ShortHash   string
28 +
	Subject     string
29 +
	Body        string
30 +
	Author      string
31 +
	AuthorEmail string
32 +
	AuthorDate  time.Time
33 +
	Parents     []string
34 +
}
35 +
36 +
type TreeEntryInfo struct {
37 +
	Name  string
38 +
	IsDir bool
39 +
	Size  int64
40 +
	Mode  string
41 +
	Hash  string
42 +
}
43 +
44 +
type BlobInfo struct {
45 +
	Name     string
46 +
	Size     int64
47 +
	IsBinary bool
48 +
	Lines    []string
49 +
	Content  string
50 +
}
51 +
52 +
type RefInfo struct {
53 +
	Name      string
54 +
	Hash      string
55 +
	ShortHash string
56 +
	Subject   string
57 +
	Author    string
58 +
	Date      time.Time
59 +
	IsTag     bool
60 +
}
61 +
62 +
type DiffFile struct {
63 +
	OldName string
64 +
	NewName string
65 +
	Status  string
66 +
	Hunks   []DiffHunk
67 +
	IsBinary bool
68 +
	Stats   DiffStats
69 +
}
70 +
71 +
type DiffStats struct {
72 +
	Added   int
73 +
	Deleted int
74 +
}
75 +
76 +
type DiffHunk struct {
77 +
	Header string
78 +
	Lines  []DiffLine
79 +
}
80 +
81 +
type DiffLine struct {
82 +
	Type    string // "context", "add", "del"
83 +
	Content string
84 +
	OldNum  int
85 +
	NewNum  int
86 +
}
87 +
88 +
const maxBlobDisplaySize = 1 << 20 // 1MB
89 +
const diffContextLines = 5
90 +
91 +
func commitToInfo(c *object.Commit) CommitInfo {
92 +
	subject, body, _ := strings.Cut(c.Message, "\n")
93 +
	body = strings.TrimSpace(body)
94 +
	parents := make([]string, len(c.ParentHashes))
95 +
	for i, p := range c.ParentHashes {
96 +
		parents[i] = p.String()
97 +
	}
98 +
	return CommitInfo{
99 +
		Hash:        c.Hash.String(),
100 +
		ShortHash:   c.Hash.String()[:8],
101 +
		Subject:     subject,
102 +
		Body:        body,
103 +
		Author:      c.Author.Name,
104 +
		AuthorEmail: c.Author.Email,
105 +
		AuthorDate:  c.Author.When,
106 +
		Parents:     parents,
107 +
	}
108 +
}
109 +
110 +
func resolveRef(repo *git.Repository, refStr string) (*plumbing.Hash, error) {
111 +
	if refStr == "" {
112 +
		head, err := repo.Head()
113 +
		if err != nil {
114 +
			return nil, err
115 +
		}
116 +
		h := head.Hash()
117 +
		return &h, nil
118 +
	}
119 +
	h, err := repo.ResolveRevision(plumbing.Revision(refStr))
120 +
	if err != nil {
121 +
		return nil, err
122 +
	}
123 +
	return h, nil
124 +
}
125 +
126 +
func resolveRefAndPath(repo *git.Repository, segments []string) (hash *plumbing.Hash, ref string, path string, err error) {
127 +
	// Try progressively longer prefixes as ref names
128 +
	for i := 1; i <= len(segments); i++ {
129 +
		candidate := strings.Join(segments[:i], "/")
130 +
		h, err := resolveRef(repo, candidate)
131 +
		if err == nil {
132 +
			rest := ""
133 +
			if i < len(segments) {
134 +
				rest = strings.Join(segments[i:], "/")
135 +
			}
136 +
			return h, candidate, rest, nil
137 +
		}
138 +
	}
139 +
	return nil, "", "", fmt.Errorf("cannot resolve ref from path segments: %s", strings.Join(segments, "/"))
140 +
}
141 +
142 +
func getDefaultBranch(repo *git.Repository) string {
143 +
	head, err := repo.Head()
144 +
	if err != nil {
145 +
		return "main"
146 +
	}
147 +
	name := head.Name()
148 +
	if name.IsBranch() {
149 +
		return name.Short()
150 +
	}
151 +
	return "main"
152 +
}
153 +
154 +
func getLog(repo *git.Repository, hash plumbing.Hash, page, perPage int) ([]CommitInfo, bool, error) {
155 +
	iter, err := repo.Log(&git.LogOptions{From: hash})
156 +
	if err != nil {
157 +
		return nil, false, err
158 +
	}
159 +
	defer iter.Close()
160 +
161 +
	skip := page * perPage
162 +
	for i := 0; i < skip; i++ {
163 +
		if _, err := iter.Next(); err != nil {
164 +
			return nil, false, nil
165 +
		}
166 +
	}
167 +
168 +
	var commits []CommitInfo
169 +
	for i := 0; i < perPage+1; i++ {
170 +
		c, err := iter.Next()
171 +
		if err == io.EOF {
172 +
			break
173 +
		}
174 +
		if err != nil {
175 +
			return nil, false, err
176 +
		}
177 +
		if i == perPage {
178 +
			return commits, true, nil
179 +
		}
180 +
		commits = append(commits, commitToInfo(c))
181 +
	}
182 +
	return commits, false, nil
183 +
}
184 +
185 +
func getTree(repo *git.Repository, hash plumbing.Hash, path string) ([]TreeEntryInfo, error) {
186 +
	commit, err := repo.CommitObject(hash)
187 +
	if err != nil {
188 +
		return nil, err
189 +
	}
190 +
	tree, err := commit.Tree()
191 +
	if err != nil {
192 +
		return nil, err
193 +
	}
194 +
195 +
	if path != "" {
196 +
		tree, err = tree.Tree(path)
197 +
		if err != nil {
198 +
			return nil, err
199 +
		}
200 +
	}
201 +
202 +
	var entries []TreeEntryInfo
203 +
	for _, e := range tree.Entries {
204 +
		isDir := !e.Mode.IsFile()
205 +
		var size int64
206 +
		if !isDir {
207 +
			f, err := tree.TreeEntryFile(&e)
208 +
			if err == nil {
209 +
				size = f.Size
210 +
			}
211 +
		}
212 +
213 +
		mode := fmt.Sprintf("%06o", uint32(e.Mode))
214 +
215 +
		entries = append(entries, TreeEntryInfo{
216 +
			Name:  e.Name,
217 +
			IsDir: isDir,
218 +
			Size:  size,
219 +
			Mode:  mode,
220 +
			Hash:  e.Hash.String()[:8],
221 +
		})
222 +
	}
223 +
224 +
	sort.Slice(entries, func(i, j int) bool {
225 +
		if entries[i].IsDir != entries[j].IsDir {
226 +
			return entries[i].IsDir
227 +
		}
228 +
		return entries[i].Name < entries[j].Name
229 +
	})
230 +
231 +
	return entries, nil
232 +
}
233 +
234 +
func getBlob(repo *git.Repository, hash plumbing.Hash, path string) (*BlobInfo, error) {
235 +
	commit, err := repo.CommitObject(hash)
236 +
	if err != nil {
237 +
		return nil, err
238 +
	}
239 +
	tree, err := commit.Tree()
240 +
	if err != nil {
241 +
		return nil, err
242 +
	}
243 +
	f, err := tree.File(path)
244 +
	if err != nil {
245 +
		return nil, err
246 +
	}
247 +
248 +
	info := &BlobInfo{
249 +
		Name: f.Name,
250 +
		Size: f.Size,
251 +
	}
252 +
253 +
	isBin, err := f.IsBinary()
254 +
	if err != nil {
255 +
		return nil, err
256 +
	}
257 +
	info.IsBinary = isBin
258 +
259 +
	if isBin || f.Size > maxBlobDisplaySize {
260 +
		info.IsBinary = true
261 +
		return info, nil
262 +
	}
263 +
264 +
	content, err := f.Contents()
265 +
	if err != nil {
266 +
		return nil, err
267 +
	}
268 +
269 +
	if !utf8.ValidString(content) {
270 +
		info.IsBinary = true
271 +
		return info, nil
272 +
	}
273 +
274 +
	info.Content = content
275 +
	info.Lines = strings.Split(content, "\n")
276 +
	// Remove trailing empty line from final newline
277 +
	if len(info.Lines) > 0 && info.Lines[len(info.Lines)-1] == "" {
278 +
		info.Lines = info.Lines[:len(info.Lines)-1]
279 +
	}
280 +
281 +
	return info, nil
282 +
}
283 +
284 +
func getRawBlob(repo *git.Repository, hash plumbing.Hash, path string) (io.ReadCloser, string, int64, error) {
285 +
	commit, err := repo.CommitObject(hash)
286 +
	if err != nil {
287 +
		return nil, "", 0, err
288 +
	}
289 +
	tree, err := commit.Tree()
290 +
	if err != nil {
291 +
		return nil, "", 0, err
292 +
	}
293 +
	f, err := tree.File(path)
294 +
	if err != nil {
295 +
		return nil, "", 0, err
296 +
	}
297 +
298 +
	reader, err := f.Reader()
299 +
	if err != nil {
300 +
		return nil, "", 0, err
301 +
	}
302 +
303 +
	// Detect content type from name
304 +
	ct := "application/octet-stream"
305 +
	if mtype := mimeFromPath(path); mtype != "" {
306 +
		ct = mtype
307 +
	}
308 +
309 +
	return reader, ct, f.Size, nil
310 +
}
311 +
312 +
var mimeTypes = map[string]string{
313 +
	".txt":  "text/plain; charset=utf-8",
314 +
	".md":   "text/plain; charset=utf-8",
315 +
	".go":   "text/plain; charset=utf-8",
316 +
	".rs":   "text/plain; charset=utf-8",
317 +
	".py":   "text/plain; charset=utf-8",
318 +
	".js":   "text/plain; charset=utf-8",
319 +
	".ts":   "text/plain; charset=utf-8",
320 +
	".c":    "text/plain; charset=utf-8",
321 +
	".h":    "text/plain; charset=utf-8",
322 +
	".css":  "text/css; charset=utf-8",
323 +
	".html": "text/html; charset=utf-8",
324 +
	".json": "application/json",
325 +
	".xml":  "application/xml",
326 +
	".png":  "image/png",
327 +
	".jpg":  "image/jpeg",
328 +
	".jpeg": "image/jpeg",
329 +
	".gif":  "image/gif",
330 +
	".svg":  "image/svg+xml",
331 +
	".pdf":  "application/pdf",
332 +
}
333 +
334 +
func mimeFromPath(path string) string {
335 +
	// Use a small set of common types; let the browser figure out the rest
336 +
	idx := strings.LastIndex(path, ".")
337 +
	if idx < 0 {
338 +
		return ""
339 +
	}
340 +
	ext := strings.ToLower(path[idx:])
341 +
	if t, ok := mimeTypes[ext]; ok {
342 +
		return t
343 +
	}
344 +
	return ""
345 +
}
346 +
347 +
func getCommit(repo *git.Repository, hash plumbing.Hash) (*CommitInfo, error) {
348 +
	c, err := repo.CommitObject(hash)
349 +
	if err != nil {
350 +
		return nil, err
351 +
	}
352 +
	info := commitToInfo(c)
353 +
	return &info, nil
354 +
}
355 +
356 +
func getDiff(repo *git.Repository, hash plumbing.Hash) ([]DiffFile, error) {
357 +
	commit, err := repo.CommitObject(hash)
358 +
	if err != nil {
359 +
		return nil, err
360 +
	}
361 +
362 +
	var parentTree *object.Tree
363 +
	if commit.NumParents() > 0 {
364 +
		parent, err := commit.Parent(0)
365 +
		if err != nil {
366 +
			return nil, err
367 +
		}
368 +
		parentTree, err = parent.Tree()
369 +
		if err != nil {
370 +
			return nil, err
371 +
		}
372 +
	}
373 +
374 +
	commitTree, err := commit.Tree()
375 +
	if err != nil {
376 +
		return nil, err
377 +
	}
378 +
379 +
	if parentTree == nil {
380 +
		// Initial commit: diff against empty tree
381 +
		changes, err := object.DiffTree(nil, commitTree)
382 +
		if err != nil {
383 +
			return nil, err
384 +
		}
385 +
		return changesToDiffFiles(changes)
386 +
	}
387 +
388 +
	changes, err := object.DiffTree(parentTree, commitTree)
389 +
	if err != nil {
390 +
		return nil, err
391 +
	}
392 +
	return changesToDiffFiles(changes)
393 +
}
394 +
395 +
func changesToDiffFiles(changes object.Changes) ([]DiffFile, error) {
396 +
	patch, err := changes.Patch()
397 +
	if err != nil {
398 +
		return nil, err
399 +
	}
400 +
401 +
	var files []DiffFile
402 +
	for _, fp := range patch.FilePatches() {
403 +
		from, to := fp.Files()
404 +
		df := DiffFile{
405 +
			IsBinary: fp.IsBinary(),
406 +
		}
407 +
408 +
		if from != nil {
409 +
			df.OldName = from.Path()
410 +
		}
411 +
		if to != nil {
412 +
			df.NewName = to.Path()
413 +
		}
414 +
415 +
		switch {
416 +
		case from == nil && to != nil:
417 +
			df.Status = "A"
418 +
		case from != nil && to == nil:
419 +
			df.Status = "D"
420 +
		case from != nil && to != nil && from.Path() != to.Path():
421 +
			df.Status = "R"
422 +
		default:
423 +
			df.Status = "M"
424 +
		}
425 +
426 +
		if !fp.IsBinary() {
427 +
			oldNum, newNum := 1, 1
428 +
			for _, chunk := range fp.Chunks() {
429 +
				lines := strings.Split(chunk.Content(), "\n")
430 +
				// Remove trailing empty string from split
431 +
				if len(lines) > 0 && lines[len(lines)-1] == "" {
432 +
					lines = lines[:len(lines)-1]
433 +
				}
434 +
435 +
				for _, line := range lines {
436 +
					if len(df.Hunks) == 0 {
437 +
						df.Hunks = append(df.Hunks, DiffHunk{})
438 +
					}
439 +
					last := &df.Hunks[len(df.Hunks)-1]
440 +
441 +
					switch chunk.Type() {
442 +
					case 0: // Equal
443 +
						last.Lines = append(last.Lines, DiffLine{Type: "context", Content: line, OldNum: oldNum, NewNum: newNum})
444 +
						oldNum++
445 +
						newNum++
446 +
					case 1: // Add
447 +
						df.Stats.Added++
448 +
						last.Lines = append(last.Lines, DiffLine{Type: "add", Content: line, NewNum: newNum})
449 +
						newNum++
450 +
					case 2: // Delete
451 +
						df.Stats.Deleted++
452 +
						last.Lines = append(last.Lines, DiffLine{Type: "del", Content: line, OldNum: oldNum})
453 +
						oldNum++
454 +
					}
455 +
				}
456 +
			}
457 +
		}
458 +
459 +
		// Collapse context lines, keeping only diffContextLines around changes.
460 +
		if len(df.Hunks) == 1 {
461 +
			df.Hunks = collapseContext(df.Hunks[0].Lines)
462 +
		}
463 +
464 +
		files = append(files, df)
465 +
	}
466 +
	return files, nil
467 +
}
468 +
469 +
// collapseContext takes a flat list of diff lines and splits them into
470 +
// hunks, keeping only diffContextLines of context around add/del lines.
471 +
func collapseContext(lines []DiffLine) []DiffHunk {
472 +
	if len(lines) == 0 {
473 +
		return nil
474 +
	}
475 +
476 +
	// Mark which lines to keep: diffContextLines around each changed line.
477 +
	keep := make([]bool, len(lines))
478 +
	for i, l := range lines {
479 +
		if l.Type == "add" || l.Type == "del" {
480 +
			start := i - diffContextLines
481 +
			if start < 0 {
482 +
				start = 0
483 +
			}
484 +
			end := i + diffContextLines
485 +
			if end >= len(lines) {
486 +
				end = len(lines) - 1
487 +
			}
488 +
			for j := start; j <= end; j++ {
489 +
				keep[j] = true
490 +
			}
491 +
		}
492 +
	}
493 +
494 +
	// Build hunks from contiguous kept ranges.
495 +
	var hunks []DiffHunk
496 +
	var current *DiffHunk
497 +
	for i, l := range lines {
498 +
		if keep[i] {
499 +
			if current == nil {
500 +
				current = &DiffHunk{}
501 +
			}
502 +
			current.Lines = append(current.Lines, l)
503 +
		} else {
504 +
			if current != nil {
505 +
				hunks = append(hunks, *current)
506 +
				current = nil
507 +
			}
508 +
		}
509 +
	}
510 +
	if current != nil {
511 +
		hunks = append(hunks, *current)
512 +
	}
513 +
514 +
	return hunks
515 +
}
516 +
517 +
func getBranches(repo *git.Repository) ([]RefInfo, error) {
518 +
	iter, err := repo.Branches()
519 +
	if err != nil {
520 +
		return nil, err
521 +
	}
522 +
	defer iter.Close()
523 +
524 +
	var refs []RefInfo
525 +
	err = iter.ForEach(func(ref *plumbing.Reference) error {
526 +
		c, err := repo.CommitObject(ref.Hash())
527 +
		if err != nil {
528 +
			return nil
529 +
		}
530 +
		refs = append(refs, RefInfo{
531 +
			Name:      ref.Name().Short(),
532 +
			Hash:      ref.Hash().String(),
533 +
			ShortHash: ref.Hash().String()[:8],
534 +
			Subject:   firstLine(c.Message),
535 +
			Author:    c.Author.Name,
536 +
			Date:      c.Author.When,
537 +
		})
538 +
		return nil
539 +
	})
540 +
541 +
	sort.Slice(refs, func(i, j int) bool {
542 +
		return refs[i].Date.After(refs[j].Date)
543 +
	})
544 +
545 +
	return refs, err
546 +
}
547 +
548 +
func getTags(repo *git.Repository) ([]RefInfo, error) {
549 +
	iter, err := repo.Tags()
550 +
	if err != nil {
551 +
		return nil, err
552 +
	}
553 +
	defer iter.Close()
554 +
555 +
	var refs []RefInfo
556 +
	err = iter.ForEach(func(ref *plumbing.Reference) error {
557 +
		hash := ref.Hash()
558 +
559 +
		// Try to peel annotated tag
560 +
		tagObj, err := repo.TagObject(hash)
561 +
		if err == nil {
562 +
			commit, err := tagObj.Commit()
563 +
			if err == nil {
564 +
				refs = append(refs, RefInfo{
565 +
					Name:      ref.Name().Short(),
566 +
					Hash:      commit.Hash.String(),
567 +
					ShortHash: commit.Hash.String()[:8],
568 +
					Subject:   firstLine(commit.Message),
569 +
					Author:    commit.Author.Name,
570 +
					Date:      commit.Author.When,
571 +
					IsTag:     true,
572 +
				})
573 +
				return nil
574 +
			}
575 +
		}
576 +
577 +
		// Lightweight tag - try as commit directly
578 +
		c, err := repo.CommitObject(hash)
579 +
		if err != nil {
580 +
			return nil
581 +
		}
582 +
		refs = append(refs, RefInfo{
583 +
			Name:      ref.Name().Short(),
584 +
			Hash:      hash.String(),
585 +
			ShortHash: hash.String()[:8],
586 +
			Subject:   firstLine(c.Message),
587 +
			Author:    c.Author.Name,
588 +
			Date:      c.Author.When,
589 +
			IsTag:     true,
590 +
		})
591 +
		return nil
592 +
	})
593 +
594 +
	sort.Slice(refs, func(i, j int) bool {
595 +
		return refs[i].Date.After(refs[j].Date)
596 +
	})
597 +
598 +
	return refs, err
599 +
}
600 +
601 +
// isTreePath checks if a path in a commit is a directory
602 +
func isTreePath(repo *git.Repository, hash plumbing.Hash, path string) bool {
603 +
	commit, err := repo.CommitObject(hash)
604 +
	if err != nil {
605 +
		return false
606 +
	}
607 +
	tree, err := commit.Tree()
608 +
	if err != nil {
609 +
		return false
610 +
	}
611 +
	if path == "" {
612 +
		return true
613 +
	}
614 +
	_, err = tree.Tree(path)
615 +
	return err == nil
616 +
}
617 +
618 +
type TreeNode struct {
619 +
	Name     string
620 +
	IsDir    bool
621 +
	Size     int64
622 +
	Path     string
623 +
	Depth    int
624 +
	IsOpen   bool
625 +
	IsActive bool
626 +
}
627 +
628 +
func buildTreeNodes(repo *git.Repository, hash plumbing.Hash, activePath string) []TreeNode {
629 +
	var activeComponents []string
630 +
	if activePath != "" {
631 +
		activeComponents = strings.Split(activePath, "/")
632 +
	}
633 +
	return buildTreeLevel(repo, hash, "", 0, activeComponents)
634 +
}
635 +
636 +
func buildTreeLevel(repo *git.Repository, hash plumbing.Hash, basePath string, depth int, activeComponents []string) []TreeNode {
637 +
	entries, err := getTree(repo, hash, basePath)
638 +
	if err != nil {
639 +
		return nil
640 +
	}
641 +
642 +
	var nodes []TreeNode
643 +
	for _, e := range entries {
644 +
		fullPath := e.Name
645 +
		if basePath != "" {
646 +
			fullPath = basePath + "/" + e.Name
647 +
		}
648 +
649 +
		isOpen := false
650 +
		isActive := false
651 +
652 +
		if len(activeComponents) > 0 && e.Name == activeComponents[0] {
653 +
			if e.IsDir {
654 +
				isOpen = true
655 +
				if len(activeComponents) == 1 {
656 +
					isActive = true
657 +
				}
658 +
			} else if len(activeComponents) == 1 {
659 +
				isActive = true
660 +
			}
661 +
		}
662 +
663 +
		nodes = append(nodes, TreeNode{
664 +
			Name:     e.Name,
665 +
			IsDir:    e.IsDir,
666 +
			Size:     e.Size,
667 +
			Path:     fullPath,
668 +
			Depth:    depth,
669 +
			IsOpen:   isOpen,
670 +
			IsActive: isActive,
671 +
		})
672 +
673 +
		if isOpen {
674 +
			var childActive []string
675 +
			if len(activeComponents) > 1 {
676 +
				childActive = activeComponents[1:]
677 +
			}
678 +
			children := buildTreeLevel(repo, hash, fullPath, depth+1, childActive)
679 +
			nodes = append(nodes, children...)
680 +
		}
681 +
	}
682 +
683 +
	return nodes
684 +
}
685 +
686 +
func getReadme(repo *git.Repository, hash plumbing.Hash, dir string) *BlobInfo {
687 +
	commit, err := repo.CommitObject(hash)
688 +
	if err != nil {
689 +
		return nil
690 +
	}
691 +
	root, err := commit.Tree()
692 +
	if err != nil {
693 +
		return nil
694 +
	}
695 +
	tree := root
696 +
	if dir != "" {
697 +
		tree, err = root.Tree(dir)
698 +
		if err != nil {
699 +
			return nil
700 +
		}
701 +
	}
702 +
	for _, entry := range tree.Entries {
703 +
		lower := strings.ToLower(entry.Name)
704 +
		if lower == "readme" || lower == "readme.md" || lower == "readme.txt" {
705 +
			path := entry.Name
706 +
			if dir != "" {
707 +
				path = dir + "/" + entry.Name
708 +
			}
709 +
			blob, err := getBlob(repo, hash, path)
710 +
			if err != nil {
711 +
				return nil
712 +
			}
713 +
			return blob
714 +
		}
715 +
	}
716 +
	return nil
717 +
}
718 +
719 +
func firstLine(s string) string {
720 +
	if i := strings.IndexByte(s, '\n'); i >= 0 {
721 +
		return s[:i]
722 +
	}
723 +
	return s
724 +
}
go.mod added +27 -0
1 +
module source-browser
2 +
3 +
go 1.25.7
4 +
5 +
require github.com/go-git/go-git/v5 v5.16.5
6 +
7 +
require (
8 +
	dario.cat/mergo v1.0.0 // indirect
9 +
	github.com/Microsoft/go-winio v0.6.2 // indirect
10 +
	github.com/ProtonMail/go-crypto v1.1.6 // indirect
11 +
	github.com/cloudflare/circl v1.6.1 // indirect
12 +
	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
13 +
	github.com/emirpasic/gods v1.18.1 // indirect
14 +
	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
15 +
	github.com/go-git/go-billy/v5 v5.6.2 // indirect
16 +
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
17 +
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
18 +
	github.com/kevinburke/ssh_config v1.2.0 // indirect
19 +
	github.com/pjbgf/sha1cd v0.3.2 // indirect
20 +
	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
21 +
	github.com/skeema/knownhosts v1.3.1 // indirect
22 +
	github.com/xanzy/ssh-agent v0.3.3 // indirect
23 +
	golang.org/x/crypto v0.45.0 // indirect
24 +
	golang.org/x/net v0.47.0 // indirect
25 +
	golang.org/x/sys v0.38.0 // indirect
26 +
	gopkg.in/warnings.v0 v0.1.2 // indirect
27 +
)
go.sum added +102 -0
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.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
5 +
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
6 +
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
7 +
github.com/ProtonMail/go-crypto v1.1.6/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.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
13 +
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
14 +
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
15 +
github.com/cyphar/filepath-securejoin v0.4.1/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.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
20 +
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
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.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
28 +
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
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.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s=
32 +
github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M=
33 +
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
34 +
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
35 +
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
36 +
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
51 +
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
57 +
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
62 +
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
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 +
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
71 +
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
72 +
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
73 +
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
74 +
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
75 +
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
76 +
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
77 +
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
78 +
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
79 +
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
80 +
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
81 +
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
82 +
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
83 +
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
84 +
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
85 +
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
86 +
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
87 +
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
88 +
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
89 +
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
90 +
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
91 +
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
92 +
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
93 +
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
94 +
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
95 +
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
96 +
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
97 +
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
98 +
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
99 +
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
100 +
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
101 +
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
102 +
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
handler.go added +360 -0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"io"
6 +
	"net/http"
7 +
	"strconv"
8 +
	"strings"
9 +
10 +
	"github.com/go-git/go-git/v5/plumbing"
11 +
)
12 +
13 +
type pageData struct {
14 +
	SiteTitle   string
15 +
	BaseURL     string
16 +
	Repo        string
17 +
	Description string
18 +
	Section     string
19 +
	Ref         string
20 +
	CommitHash  string
21 +
	Data        any
22 +
}
23 +
24 +
func (s *server) newPageData(repo *RepoInfo, section, ref string) pageData {
25 +
	pd := pageData{
26 +
		SiteTitle: s.title,
27 +
		BaseURL:   s.baseURL,
28 +
		Section:   section,
29 +
		Ref:       ref,
30 +
	}
31 +
	if repo != nil {
32 +
		pd.Repo = repo.Name
33 +
		pd.Description = repo.Description
34 +
	}
35 +
	return pd
36 +
}
37 +
38 +
func (s *server) serveCSS(w http.ResponseWriter, r *http.Request) {
39 +
	w.Header().Set("Content-Type", "text/css; charset=utf-8")
40 +
	w.Header().Set("Cache-Control", "public, max-age=3600")
41 +
	w.Write(cssContent)
42 +
}
43 +
44 +
func (s *server) serveLogo(w http.ResponseWriter, r *http.Request) {
45 +
	w.Header().Set("Content-Type", "image/svg+xml")
46 +
	w.Header().Set("Cache-Control", "public, max-age=86400")
47 +
	w.Write(logoContent)
48 +
}
49 +
50 +
func (s *server) serveJS(w http.ResponseWriter, r *http.Request) {
51 +
	name := strings.TrimPrefix(r.URL.Path, "/js/")
52 +
	var data []byte
53 +
	switch name {
54 +
	case "hirad.js":
55 +
		data = hiradJS
56 +
	case "hiril.js":
57 +
		data = hirilJS
58 +
	default:
59 +
		http.NotFound(w, r)
60 +
		return
61 +
	}
62 +
	w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
63 +
	w.Header().Set("Cache-Control", "public, max-age=3600")
64 +
	w.Write(data)
65 +
}
66 +
67 +
func (s *server) serveFont(w http.ResponseWriter, r *http.Request) {
68 +
	name := strings.TrimPrefix(r.URL.Path, "/fonts/")
69 +
	data, err := fontsFS.ReadFile("static/fonts/" + name)
70 +
	if err != nil {
71 +
		http.NotFound(w, r)
72 +
		return
73 +
	}
74 +
	w.Header().Set("Content-Type", "font/ttf")
75 +
	w.Header().Set("Cache-Control", "public, max-age=86400")
76 +
	w.Write(data)
77 +
}
78 +
79 +
func (s *server) route(w http.ResponseWriter, r *http.Request) {
80 +
	path := strings.TrimPrefix(r.URL.Path, s.baseURL)
81 +
	path = strings.Trim(path, "/")
82 +
83 +
	if path == "" {
84 +
		s.handleIndex(w, r)
85 +
		return
86 +
	}
87 +
88 +
	segments := strings.SplitN(path, "/", 3)
89 +
	repoName := segments[0]
90 +
91 +
	repo, ok := s.repos[repoName]
92 +
	if !ok {
93 +
		s.renderError(w, r, http.StatusNotFound, "Repository not found")
94 +
		return
95 +
	}
96 +
97 +
	if len(segments) == 1 {
98 +
		s.handleSummary(w, r, repo)
99 +
		return
100 +
	}
101 +
102 +
	action := segments[1]
103 +
	rest := ""
104 +
	if len(segments) > 2 {
105 +
		rest = segments[2]
106 +
	}
107 +
108 +
	switch action {
109 +
	case "refs":
110 +
		s.handleRefs(w, r, repo)
111 +
	case "log":
112 +
		s.handleLog(w, r, repo, rest)
113 +
	case "tree":
114 +
		s.handleTree(w, r, repo, rest)
115 +
	case "commit":
116 +
		s.handleCommit(w, r, repo, rest)
117 +
	case "raw":
118 +
		s.handleRaw(w, r, repo, rest)
119 +
	default:
120 +
		s.renderError(w, r, http.StatusNotFound, "Page not found")
121 +
	}
122 +
}
123 +
124 +
func (s *server) handleIndex(w http.ResponseWriter, r *http.Request) {
125 +
	type indexData struct {
126 +
		Repos   []*RepoInfo
127 +
		IsEmpty bool
128 +
	}
129 +
	repos := make([]*RepoInfo, 0, len(s.sorted))
130 +
	for _, name := range s.sorted {
131 +
		repos = append(repos, s.repos[name])
132 +
	}
133 +
	pd := s.newPageData(nil, "", "")
134 +
	pd.Data = indexData{Repos: repos, IsEmpty: len(repos) == 0}
135 +
	s.tmpl.render(w, "index", pd)
136 +
}
137 +
138 +
type homeData struct {
139 +
	DefaultRef string
140 +
	Branches   []RefInfo
141 +
	Tree       []TreeNode
142 +
	Readme     *BlobInfo
143 +
	LastCommit *CommitInfo
144 +
	ActiveBlob *BlobInfo
145 +
	ActivePath string
146 +
	IsEmpty    bool
147 +
}
148 +
149 +
func (s *server) handleSummary(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
150 +
	s.renderHome(w, repo, "", nil, "")
151 +
}
152 +
153 +
func (s *server) renderHome(w http.ResponseWriter, repo *RepoInfo, ref string, blob *BlobInfo, activePath string) {
154 +
	if ref == "" {
155 +
		ref = getDefaultBranch(repo.Repo)
156 +
	}
157 +
158 +
	head, err := resolveRef(repo.Repo, ref)
159 +
	if err != nil {
160 +
		pd := s.newPageData(repo, "home", ref)
161 +
		pd.Data = homeData{
162 +
			DefaultRef: ref,
163 +
			IsEmpty:    true,
164 +
		}
165 +
		s.tmpl.render(w, "home", pd)
166 +
		return
167 +
	}
168 +
169 +
	tree := buildTreeNodes(repo.Repo, *head, activePath)
170 +
	lastCommit, _ := getCommit(repo.Repo, *head)
171 +
	branches, _ := getBranches(repo.Repo)
172 +
173 +
	// Show README for the active directory (or root if no active path / file selected)
174 +
	readmeDir := ""
175 +
	if activePath != "" && blob == nil {
176 +
		readmeDir = activePath
177 +
	}
178 +
	readme := getReadme(repo.Repo, *head, readmeDir)
179 +
180 +
	pd := s.newPageData(repo, "home", ref)
181 +
	pd.Data = homeData{
182 +
		DefaultRef: ref,
183 +
		Branches:   branches,
184 +
		Tree:       tree,
185 +
		Readme:     readme,
186 +
		LastCommit: lastCommit,
187 +
		ActiveBlob: blob,
188 +
		ActivePath: activePath,
189 +
	}
190 +
	s.tmpl.render(w, "home", pd)
191 +
}
192 +
193 +
func (s *server) handleLog(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
194 +
	type logData struct {
195 +
		Branches   []RefInfo
196 +
		LastCommit *CommitInfo
197 +
		Commits    []CommitInfo
198 +
		Page       int
199 +
		HasPrev    bool
200 +
		HasNext    bool
201 +
		PrevPage   int
202 +
		NextPage   int
203 +
		Ref        string
204 +
		IsEmpty    bool
205 +
	}
206 +
207 +
	page := 0
208 +
	if p := r.URL.Query().Get("page"); p != "" {
209 +
		if n, err := strconv.Atoi(p); err == nil && n >= 0 {
210 +
			page = n
211 +
		}
212 +
	}
213 +
214 +
	refStr := rest
215 +
	if refStr == "" {
216 +
		refStr = getDefaultBranch(repo.Repo)
217 +
	}
218 +
219 +
	hash, err := resolveRef(repo.Repo, refStr)
220 +
	if err != nil {
221 +
		pd := s.newPageData(repo, "log", refStr)
222 +
		pd.Data = logData{IsEmpty: true, Ref: refStr}
223 +
		s.tmpl.render(w, "log", pd)
224 +
		return
225 +
	}
226 +
227 +
	commits, hasMore, err := getLog(repo.Repo, *hash, page, 50)
228 +
	if err != nil {
229 +
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
230 +
		return
231 +
	}
232 +
233 +
	branches, _ := getBranches(repo.Repo)
234 +
	lastCommit, _ := getCommit(repo.Repo, *hash)
235 +
236 +
	pd := s.newPageData(repo, "log", refStr)
237 +
	pd.Data = logData{
238 +
		Branches:   branches,
239 +
		LastCommit: lastCommit,
240 +
		Commits:    commits,
241 +
		Page:       page,
242 +
		HasPrev:    page > 0,
243 +
		HasNext:    hasMore,
244 +
		PrevPage:   page - 1,
245 +
		NextPage:   page + 1,
246 +
		Ref:        refStr,
247 +
		IsEmpty:    len(commits) == 0,
248 +
	}
249 +
	s.tmpl.render(w, "log", pd)
250 +
}
251 +
252 +
func (s *server) handleTree(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
253 +
	if rest == "" {
254 +
		rest = getDefaultBranch(repo.Repo)
255 +
	}
256 +
257 +
	segments := strings.Split(rest, "/")
258 +
	hash, ref, path, err := resolveRefAndPath(repo.Repo, segments)
259 +
	if err != nil {
260 +
		s.renderError(w, r, http.StatusNotFound, "Ref not found")
261 +
		return
262 +
	}
263 +
264 +
	// File: show blob in content view
265 +
	if path != "" && !isTreePath(repo.Repo, *hash, path) {
266 +
		blob, err := getBlob(repo.Repo, *hash, path)
267 +
		if err != nil {
268 +
			s.renderError(w, r, http.StatusNotFound, "File not found")
269 +
			return
270 +
		}
271 +
		s.renderHome(w, repo, ref, blob, path)
272 +
		return
273 +
	}
274 +
275 +
	// Directory: expand tree to this path
276 +
	s.renderHome(w, repo, ref, nil, path)
277 +
}
278 +
279 +
func (s *server) handleCommit(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
280 +
	type commitData struct {
281 +
		Commit *CommitInfo
282 +
		Files  []DiffFile
283 +
	}
284 +
285 +
	hash := plumbing.NewHash(rest)
286 +
	commit, err := getCommit(repo.Repo, hash)
287 +
	if err != nil {
288 +
		s.renderError(w, r, http.StatusNotFound, "Commit not found")
289 +
		return
290 +
	}
291 +
292 +
	files, err := getDiff(repo.Repo, hash)
293 +
	if err != nil {
294 +
		s.renderError(w, r, http.StatusInternalServerError, err.Error())
295 +
		return
296 +
	}
297 +
298 +
	pd := s.newPageData(repo, "commit", "")
299 +
	pd.CommitHash = commit.Hash
300 +
	pd.Data = commitData{
301 +
		Commit: commit,
302 +
		Files:  files,
303 +
	}
304 +
	s.tmpl.render(w, "commit", pd)
305 +
}
306 +
307 +
func (s *server) handleRefs(w http.ResponseWriter, r *http.Request, repo *RepoInfo) {
308 +
	type refsData struct {
309 +
		Branches []RefInfo
310 +
		Tags     []RefInfo
311 +
	}
312 +
313 +
	branches, _ := getBranches(repo.Repo)
314 +
	tags, _ := getTags(repo.Repo)
315 +
316 +
	pd := s.newPageData(repo, "refs", "")
317 +
	pd.Data = refsData{Branches: branches, Tags: tags}
318 +
	s.tmpl.render(w, "refs", pd)
319 +
}
320 +
321 +
func (s *server) handleRaw(w http.ResponseWriter, r *http.Request, repo *RepoInfo, rest string) {
322 +
	if rest == "" {
323 +
		s.renderError(w, r, http.StatusNotFound, "No path specified")
324 +
		return
325 +
	}
326 +
327 +
	segments := strings.Split(rest, "/")
328 +
	hash, _, path, err := resolveRefAndPath(repo.Repo, segments)
329 +
	if err != nil {
330 +
		s.renderError(w, r, http.StatusNotFound, "Ref not found")
331 +
		return
332 +
	}
333 +
	if path == "" {
334 +
		s.renderError(w, r, http.StatusNotFound, "No file path")
335 +
		return
336 +
	}
337 +
338 +
	reader, contentType, size, err := getRawBlob(repo.Repo, *hash, path)
339 +
	if err != nil {
340 +
		s.renderError(w, r, http.StatusNotFound, "File not found")
341 +
		return
342 +
	}
343 +
	defer reader.Close()
344 +
345 +
	w.Header().Set("Content-Type", contentType)
346 +
	w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
347 +
	io.Copy(w, reader)
348 +
}
349 +
350 +
func (s *server) renderError(w http.ResponseWriter, r *http.Request, code int, message string) {
351 +
	type errorData struct {
352 +
		Code    int
353 +
		Message string
354 +
		Path    string
355 +
	}
356 +
	w.WriteHeader(code)
357 +
	pd := s.newPageData(nil, "", "")
358 +
	pd.Data = errorData{Code: code, Message: message, Path: r.URL.Path}
359 +
	s.tmpl.render(w, "error", pd)
360 +
}
main.go added +196 -0
1 +
package main
2 +
3 +
import (
4 +
	"flag"
5 +
	"fmt"
6 +
	"log"
7 +
	"net/http"
8 +
	"os"
9 +
	"path/filepath"
10 +
	"sort"
11 +
	"strings"
12 +
	"time"
13 +
14 +
	git "github.com/go-git/go-git/v5"
15 +
)
16 +
17 +
type server struct {
18 +
	repos    map[string]*RepoInfo
19 +
	sorted   []string
20 +
	tmpl     *templateSet
21 +
	title    string
22 +
	baseURL  string
23 +
	scanPath string
24 +
	username string
25 +
	password string
26 +
}
27 +
28 +
func main() {
29 +
	listen := flag.String("listen", ":8080", "listen address")
30 +
	scanPath := flag.String("scan-path", ".", "path to scan for git repos")
31 +
	title := flag.String("title", "radiant computer repositories", "site title")
32 +
	baseURL := flag.String("base-url", "", "base URL prefix (e.g. /git)")
33 +
	nonBare := flag.Bool("non-bare", false, "also scan for non-bare repos (dirs containing .git)")
34 +
	username := flag.String("username", "", "HTTP basic auth username (requires -password)")
35 +
	password := flag.String("password", "", "HTTP basic auth password (requires -username)")
36 +
	flag.Parse()
37 +
38 +
	if (*username == "") != (*password == "") {
39 +
		log.Fatal("-username and -password must both be set, or both be omitted")
40 +
	}
41 +
42 +
	abs, err := filepath.Abs(*scanPath)
43 +
	if err != nil {
44 +
		log.Fatalf("invalid scan path: %v", err)
45 +
	}
46 +
47 +
	repos, err := scanRepositories(abs, *nonBare)
48 +
	if err != nil {
49 +
		log.Fatalf("scan repositories: %v", err)
50 +
	}
51 +
52 +
	sorted := make([]string, 0, len(repos))
53 +
	for name := range repos {
54 +
		sorted = append(sorted, name)
55 +
	}
56 +
	sort.Slice(sorted, func(i, j int) bool {
57 +
		return repos[sorted[i]].LastUpdated.After(repos[sorted[j]].LastUpdated)
58 +
	})
59 +
60 +
	tmpl, err := loadTemplates()
61 +
	if err != nil {
62 +
		log.Fatalf("load templates: %v", err)
63 +
	}
64 +
65 +
	srv := &server{
66 +
		repos:    repos,
67 +
		sorted:   sorted,
68 +
		tmpl:     tmpl,
69 +
		title:    *title,
70 +
		baseURL:  strings.TrimRight(*baseURL, "/"),
71 +
		scanPath: abs,
72 +
		username: *username,
73 +
		password: *password,
74 +
	}
75 +
76 +
	mux := http.NewServeMux()
77 +
	mux.HandleFunc("/style.css", srv.serveCSS)
78 +
	mux.HandleFunc("/radiant.svg", srv.serveLogo)
79 +
	mux.HandleFunc("/js/", srv.serveJS)
80 +
	mux.HandleFunc("/fonts/", srv.serveFont)
81 +
	mux.HandleFunc("/", srv.route)
82 +
83 +
	var handler http.Handler = mux
84 +
	if srv.username != "" {
85 +
		handler = srv.basicAuth(mux)
86 +
	}
87 +
88 +
	log.Printf("listening on %s (scanning %s, %d repos)", *listen, abs, len(repos))
89 +
	if err := http.ListenAndServe(*listen, handler); err != nil {
90 +
		log.Fatal(err)
91 +
	}
92 +
}
93 +
94 +
func (s *server) basicAuth(next http.Handler) http.Handler {
95 +
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96 +
		u, p, ok := r.BasicAuth()
97 +
		if !ok || u != s.username || p != s.password {
98 +
			w.Header().Set("WWW-Authenticate", `Basic realm="source-browser"`)
99 +
			http.Error(w, "Unauthorized", http.StatusUnauthorized)
100 +
			return
101 +
		}
102 +
		next.ServeHTTP(w, r)
103 +
	})
104 +
}
105 +
106 +
func scanRepositories(root string, nonBare bool) (map[string]*RepoInfo, error) {
107 +
	repos := make(map[string]*RepoInfo)
108 +
109 +
	entries, err := os.ReadDir(root)
110 +
	if err != nil {
111 +
		return nil, fmt.Errorf("read dir %s: %w", root, err)
112 +
	}
113 +
114 +
	for _, entry := range entries {
115 +
		if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
116 +
			continue
117 +
		}
118 +
		dirPath := filepath.Join(root, entry.Name())
119 +
120 +
		var repo *git.Repository
121 +
		var gitDir string
122 +
123 +
		if isBareRepo(dirPath) {
124 +
			gitDir = dirPath
125 +
		} else if nonBare && isWorkTreeRepo(dirPath) {
126 +
			gitDir = filepath.Join(dirPath, ".git")
127 +
		} else {
128 +
			continue
129 +
		}
130 +
131 +
		// Repos are private by default; skip unless a 'public' file exists.
132 +
		if _, err := os.Stat(filepath.Join(gitDir, "public")); err != nil {
133 +
			continue
134 +
		}
135 +
136 +
		repo, err = git.PlainOpen(dirPath)
137 +
		if err != nil {
138 +
			log.Printf("skip %s: %v", entry.Name(), err)
139 +
			continue
140 +
		}
141 +
142 +
		name := strings.TrimSuffix(entry.Name(), ".git")
143 +
144 +
		desc := ""
145 +
		descBytes, err := os.ReadFile(filepath.Join(gitDir, "description"))
146 +
		if err == nil {
147 +
			d := strings.TrimSpace(string(descBytes))
148 +
			if d != "Unnamed repository; edit this file 'description' to name the repository." {
149 +
				desc = d
150 +
			}
151 +
		}
152 +
153 +
		owner := ""
154 +
		ownerBytes, err := os.ReadFile(filepath.Join(gitDir, "owner"))
155 +
		if err == nil {
156 +
			owner = strings.TrimSpace(string(ownerBytes))
157 +
		}
158 +
159 +
		var lastUpdated time.Time
160 +
		head, err := repo.Head()
161 +
		if err == nil {
162 +
			c, err := repo.CommitObject(head.Hash())
163 +
			if err == nil {
164 +
				lastUpdated = c.Author.When
165 +
			}
166 +
		}
167 +
168 +
		repos[name] = &RepoInfo{
169 +
			Name:        name,
170 +
			Path:        dirPath,
171 +
			Description: desc,
172 +
			Owner:       owner,
173 +
			LastUpdated: lastUpdated,
174 +
			Repo:        repo,
175 +
		}
176 +
	}
177 +
178 +
	return repos, nil
179 +
}
180 +
181 +
func isWorkTreeRepo(path string) bool {
182 +
	info, err := os.Stat(filepath.Join(path, ".git"))
183 +
	if err != nil {
184 +
		return false
185 +
	}
186 +
	return info.IsDir()
187 +
}
188 +
189 +
func isBareRepo(path string) bool {
190 +
	for _, f := range []string{"HEAD", "objects", "refs"} {
191 +
		if _, err := os.Stat(filepath.Join(path, f)); err != nil {
192 +
			return false
193 +
		}
194 +
	}
195 +
	return true
196 +
}
static/fonts/IBMPlexMono-Italic.ttf added +0 -0

Binary file changed.

static/fonts/IBMPlexMono-Regular.ttf added +0 -0

Binary file changed.

static/fonts/IBMPlexMono-SemiBold.ttf added +0 -0

Binary file changed.

static/fonts/RethinkSans-Italic.ttf added +0 -0

Binary file changed.

static/fonts/RethinkSans.ttf added +0 -0

Binary file changed.

static/js/hirad.js added +82 -0
1 +
(function (hirad) {
2 +
//
3 +
// hirad - Radiance Syntax Highlighter
4 +
//
5 +
// Copyright (c) 2020-2025 Alexis Sellier
6 +
//
7 +
const selector = hirad || '.language-radiance';
8 +
const keywords = [
9 +
    'fn', 'pub', 'if', 'else', 'for', 'while', 'break', 'switch', 'match',
10 +
    'record', 'union', 'const', 'align', 'let', 'use', 'mod', 'case',
11 +
    'continue', 'return', 'true', 'false', 'loop', 'extern', 'panic',
12 +
    'device', 'register', 'bit', 'catch', 'throw', 'throws', 'test',
13 +
    'at', 'mut', 'nil', 'undefined', 'static', 'in', 'is', 'where',
14 +
    'as', 'and', 'or', 'xor', 'not', 'try', 'atomic', 'select'
15 +
];
16 +
const types = ['bool', 'u8', 'u16', 'u32', 'i8', 'i16', 'i32', 'f32', 'void', 'opaque'];
17 +
18 +
// Syntax definition.
19 +
//
20 +
// The key becomes the class name of the span around the matched block of code.
21 +
const syntax = [
22 +
    ['comment', /(\/\/[^\n]*)/g],
23 +
    ['string' , /("(?:(?!").|\\.)*"|'[^']{1,2}')/g],
24 +
    ['number' , /\b(0x[0-9a-fA-F]+|0b[01]+|[0-9]+(?:\.[0-9]+)?)\b/g],
25 +
    ['ref'    , /(&|&'|\*)\b/g],
26 +
    ['delim'  , /(->|=>|\(|\)|\{|\}|\[|\])/g],
27 +
    ['builtin', /(@[a-zA-Z]+)/g],
28 +
    ['access' , /(\.|::)/g],
29 +
    ['op'     , /(=|!=|\.\.|\+|-|\*|\/|%|\?{1,2}|!{1,2}|>=?|<=?)/g],
30 +
    ['keyword', new RegExp('\\b(' + keywords.join('|') + ')\\b', 'g')],
31 +
    ['type'   , new RegExp('\\b(' + types.join('|') + ')\\b', 'g')],
32 +
];
33 +
34 +
const table = {};
35 +
36 +
// Encode ASCII characters to Braille to avoid conflicts between patterns.
37 +
const encode = (str) => {
38 +
    const encoded = [...str].map(c =>
39 +
        c.charCodeAt(0) <= 127 ? String.fromCharCode(c.charCodeAt(0) + 0x2800) : c
40 +
    ).join('');
41 +
    table[encoded] = str;
42 +
43 +
    return encoded;
44 +
};
45 +
46 +
// Decode Braille back to ASCII.
47 +
const decode = (str) =>
48 +
    table[str] || [...str].map(c => {
49 +
        const code = c.charCodeAt(0) - 0x2800;
50 +
        return code >= 0 && code <= 127 ? String.fromCharCode(code) : c;
51 +
    }).join('');
52 +
53 +
// Escape HTML special characters.
54 +
const escape = (str) =>
55 +
    str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
56 +
57 +
// Highlight all matching elements.
58 +
for (const node of document.querySelectorAll(selector)) {
59 +
    for (const child of node.childNodes) {
60 +
        if (child.nodeType !== Node.TEXT_NODE) continue;
61 +
        if (/^\$\s/.test(child.nodeValue.trim())) continue; // Skip shell snippets.
62 +
63 +
        for (const [cls, re] of syntax) {
64 +
            child.nodeValue = child.nodeValue.replace(re, (_, m) =>
65 +
                '\u00ab' + encode(cls) + '\u00b7' +
66 +
                           encode(m)   +
67 +
                '\u00b7' + encode(cls) + '\u00bb'
68 +
            );
69 +
        }
70 +
    }
71 +
    node.innerHTML = node.innerHTML.replace(
72 +
        /\u00ab(.+?)\u00b7(.+?)\u00b7\1\u00bb/g,
73 +
        (_, name, value) => {
74 +
            value = value.replace(/\u00ab[^\u00b7]+\u00b7|\u00b7[^\u00bb]+\u00bb/g, '');
75 +
            return '<span class="' + decode(name) + '">'   +
76 +
                                     escape(decode(value)) +
77 +
                   '</span>';
78 +
        }
79 +
    );
80 +
}
81 +
82 +
})(window.hirad);
static/js/hiril.js added +87 -0
1 +
(function (hiril) {
2 +
//
3 +
// hiril - Radiance IL Syntax Highlighter
4 +
//
5 +
// Copyright (c) 2020-2025 Alexis Sellier
6 +
//
7 +
const selector = hiril || '.language-ril';
8 +
const keywords = ['fn', 'data', 'extern', 'mut', 'align'];
9 +
const instrs = [
10 +
    'reserve', 'load', 'sload', 'store', 'blit', 'copy',
11 +
    'add', 'sub', 'mul', 'sdiv', 'udiv', 'srem', 'urem', 'neg',
12 +
    'eq', 'ne', 'slt', 'sge', 'ult', 'uge',
13 +
    'and', 'or', 'xor', 'shl', 'sshr', 'ushr', 'not',
14 +
    'zext', 'sext', 'call', 'ret', 'jmp',
15 +
    'switch', 'unreachable', 'ecall', 'ebreak'
16 +
];
17 +
const branchOps = ['br.eq', 'br.ne', 'br.slt', 'br.ult'];
18 +
const dataItems = ['str', 'sym', 'undef'];
19 +
const types = ['w8', 'w16', 'w32', 'w64'];
20 +
21 +
// Syntax definition.
22 +
//
23 +
// The key becomes the class name of the span around the matched block of code.
24 +
const syntax = [
25 +
    ['comment', /(\/\/[^\n]*)/g],
26 +
    ['string' , /("[^"]*")/g],
27 +
    ['reg'    , /(%[0-9]+)/g],
28 +
    ['label'  , /(@[A-Za-z_][A-Za-z#0-9_]*)/g],
29 +
    ['symbol' , /(\$[A-Za-z_][A-Za-z0-9_]*)/g],
30 +
    ['number' , /\b(-?[0-9]+|0x[a-fA-F0-9]+)\b/g],
31 +
    ['delim'  , /([{}();:,])/g],
32 +
    ['keyword', new RegExp('\\b(' + keywords.join('|') + ')\\b', 'g')],
33 +
    ['instr'  , new RegExp('\\b(' + branchOps.join('|').replace(/\./g, '\\.') + ')\\b', 'g')],
34 +
    ['instr'  , new RegExp('\\b(' + instrs.join('|') + ')\\b', 'g')],
35 +
    ['type'   , new RegExp('\\b(' + dataItems.join('|') + ')\\b', 'g')],
36 +
    ['type'   , new RegExp('\\b(' + types.join('|') + ')\\b', 'g')],
37 +
];
38 +
39 +
const table = {};
40 +
41 +
// Encode ASCII characters to Braille to avoid conflicts between patterns.
42 +
const encode = (str) => {
43 +
    const encoded = [...str].map(c =>
44 +
        c.charCodeAt(0) <= 127 ? String.fromCharCode(c.charCodeAt(0) + 0x2800) : c
45 +
    ).join('');
46 +
    table[encoded] = str;
47 +
48 +
    return encoded;
49 +
};
50 +
51 +
// Decode Braille back to ASCII.
52 +
const decode = (str) =>
53 +
    table[str] || [...str].map(c => {
54 +
        const code = c.charCodeAt(0) - 0x2800;
55 +
        return code >= 0 && code <= 127 ? String.fromCharCode(code) : c;
56 +
    }).join('');
57 +
58 +
// Escape HTML special characters.
59 +
const escape = (str) =>
60 +
    str.replace(/</g, '&lt;').replace(/>/g, '&gt;');
61 +
62 +
// Highlight all matching elements.
63 +
for (const node of document.querySelectorAll(selector)) {
64 +
    for (const child of node.childNodes) {
65 +
        if (child.nodeType !== Node.TEXT_NODE) continue;
66 +
        if (/^\$\s/.test(child.nodeValue.trim())) continue; // Skip shell snippets.
67 +
68 +
        for (const [cls, re] of syntax) {
69 +
            child.nodeValue = child.nodeValue.replace(re, (_, m) =>
70 +
                '\u00ab' + encode(cls) + '\u00b7' +
71 +
                           encode(m)   +
72 +
                '\u00b7' + encode(cls) + '\u00bb'
73 +
            );
74 +
        }
75 +
    }
76 +
    node.innerHTML = node.innerHTML.replace(
77 +
        /\u00ab(.+?)\u00b7(.+?)\u00b7\1\u00bb/g,
78 +
        (_, name, value) => {
79 +
            value = value.replace(/\u00ab[^\u00b7]+\u00b7|\u00b7[^\u00bb]+\u00bb/g, '');
80 +
            return '<span class="' + decode(name) + '">'   +
81 +
                                     escape(decode(value)) +
82 +
                   '</span>';
83 +
        }
84 +
    );
85 +
}
86 +
87 +
})(window.hiril);
static/radiant.svg added +32 -0
1 +
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
2 +
<rect x="1" y="1" width="1" height="1" fill="currentColor"/>
3 +
<rect x="7" y="1" width="1" height="1" fill="currentColor"/>
4 +
<rect x="13" y="1" width="1" height="1" fill="currentColor"/>
5 +
<rect x="2" y="2" width="1" height="1" fill="currentColor"/>
6 +
<rect x="12" y="2" width="1" height="1" fill="currentColor"/>
7 +
<rect x="3" y="3" width="1" height="1" fill="currentColor"/>
8 +
<rect x="7" y="3" width="1" height="1" fill="currentColor"/>
9 +
<rect x="11" y="3" width="1" height="1" fill="currentColor"/>
10 +
<rect x="4" y="4" width="1" height="1" fill="currentColor"/>
11 +
<rect x="10" y="4" width="1" height="1" fill="currentColor"/>
12 +
<rect x="5" y="5" width="1" height="1" fill="currentColor"/>
13 +
<rect x="9" y="5" width="1" height="1" fill="currentColor"/>
14 +
<rect x="0" y="7" width="1" height="1" fill="currentColor"/>
15 +
<rect x="2" y="7" width="1" height="1" fill="currentColor"/>
16 +
<rect x="3" y="7" width="1" height="1" fill="currentColor"/>
17 +
<rect x="11" y="7" width="1" height="1" fill="currentColor"/>
18 +
<rect x="12" y="7" width="1" height="1" fill="currentColor"/>
19 +
<rect x="14" y="7" width="1" height="1" fill="currentColor"/>
20 +
<rect x="5" y="9" width="1" height="1" fill="currentColor"/>
21 +
<rect x="9" y="9" width="1" height="1" fill="currentColor"/>
22 +
<rect x="4" y="10" width="1" height="1" fill="currentColor"/>
23 +
<rect x="10" y="10" width="1" height="1" fill="currentColor"/>
24 +
<rect x="3" y="11" width="1" height="1" fill="currentColor"/>
25 +
<rect x="7" y="11" width="1" height="1" fill="currentColor"/>
26 +
<rect x="11" y="11" width="1" height="1" fill="currentColor"/>
27 +
<rect x="2" y="12" width="1" height="1" fill="currentColor"/>
28 +
<rect x="12" y="12" width="1" height="1" fill="currentColor"/>
29 +
<rect x="1" y="13" width="1" height="1" fill="currentColor"/>
30 +
<rect x="7" y="13" width="1" height="1" fill="currentColor"/>
31 +
<rect x="13" y="13" width="1" height="1" fill="currentColor"/>
32 +
</svg>
static/style.css added +672 -0
1 +
@font-face {
2 +
  font-family: "RethinkSans";
3 +
  font-style: normal;
4 +
  font-weight: 400 800;
5 +
  font-display: swap;
6 +
  font-optical-sizing: auto;
7 +
  src: url(/fonts/RethinkSans.ttf);
8 +
}
9 +
@font-face {
10 +
  font-family: "RethinkSans";
11 +
  font-style: italic;
12 +
  font-weight: 400 800;
13 +
  font-display: swap;
14 +
  font-optical-sizing: auto;
15 +
  src: url(/fonts/RethinkSans-Italic.ttf);
16 +
}
17 +
@font-face {
18 +
  font-family: "IBM Plex Mono";
19 +
  font-style: normal;
20 +
  font-weight: 400;
21 +
  font-display: swap;
22 +
  src: url(/fonts/IBMPlexMono-Regular.ttf) format('truetype');
23 +
}
24 +
@font-face {
25 +
  font-family: "IBM Plex Mono";
26 +
  font-style: normal;
27 +
  font-weight: 600;
28 +
  font-display: swap;
29 +
  src: url(/fonts/IBMPlexMono-SemiBold.ttf) format('truetype');
30 +
}
31 +
@font-face {
32 +
  font-family: "IBM Plex Mono";
33 +
  font-style: italic;
34 +
  font-weight: 400;
35 +
  font-display: swap;
36 +
  src: url(/fonts/IBMPlexMono-Italic.ttf) format('truetype');
37 +
}
38 +
39 +
:root {
40 +
  --fg: #112;
41 +
  --fg-dim: #666;
42 +
  --fg-headers: #222;
43 +
  --bg: #d3d1d1;
44 +
  --bg-alt: #dedddd;
45 +
  --border: #888;
46 +
  --link: #223377;
47 +
  --link-dim: #334477;
48 +
  --add-bg: #cdd6cd;
49 +
  --add-fg: #2e7d32;
50 +
  --del-bg: #d9cdcd;
51 +
  --del-fg: #c62828;
52 +
  --color-dim: #334;
53 +
  --color-dimmer: #666;
54 +
  --color-secondary: #225533;
55 +
  --color-highlight: #223377;
56 +
  --color-highlight-dim: #334477;
57 +
  --mono: "IBM Plex Mono", monospace;
58 +
  --sans: "RethinkSans", sans-serif;
59 +
}
60 +
61 +
* { margin: 0; padding: 0; box-sizing: border-box; }
62 +
63 +
body {
64 +
  font-family: var(--sans);
65 +
  font-size: 16px;
66 +
  line-height: 1.4;
67 +
  color: var(--fg);
68 +
  background: var(--bg);
69 +
  -webkit-font-smoothing: antialiased;
70 +
  -moz-osx-font-smoothing: grayscale;
71 +
}
72 +
73 +
::selection {
74 +
  color: var(--bg);
75 +
  background: var(--link);
76 +
}
77 +
78 +
.container {
79 +
  margin: 0 auto;
80 +
  padding: 0 1rem 2rem;
81 +
}
82 +
83 +
code, pre, kbd { font-family: var(--mono); font-size: 0.875rem; }
84 +
85 +
a { color: var(--link); text-decoration: none; }
86 +
a:hover { text-decoration: underline; }
87 +
88 +
/* --- Nav --- */
89 +
90 +
.repo-nav {
91 +
  border-bottom: 1px solid var(--border);
92 +
  margin: 0 0 1rem;
93 +
  padding: 0.25rem 0 0;
94 +
  display: flex;
95 +
  align-items: flex-end;
96 +
}
97 +
.repo-name {
98 +
  display: flex;
99 +
  align-items: baseline;
100 +
  gap: 0.75rem;
101 +
  font-family: var(--sans);
102 +
  font-weight: 800;
103 +
  font-size: 1.25rem;
104 +
  margin-bottom: -1px;
105 +
  padding: 0.5rem 0;
106 +
}
107 +
.repo-name a { color: var(--fg); text-decoration: none; }
108 +
.repo-name a:hover { text-decoration: underline; }
109 +
.repo-name .logo-link { text-decoration: none; align-self: center; line-height: 0; }
110 +
.repo-name .logo-link:hover { text-decoration: none; }
111 +
.repo-name .logo { width: 16px; height: 16px; display: block; }
112 +
.repo-desc {
113 +
  color: var(--fg-dim);
114 +
  font-size: 1rem;
115 +
  font-weight: 400;
116 +
}
117 +
.tab {
118 +
  display: inline-block;
119 +
  padding: 0.5rem 0.75rem;
120 +
  color: var(--fg-dim);
121 +
  border: 1px solid transparent;
122 +
  border-bottom: none;
123 +
  margin-bottom: -1px;
124 +
  text-decoration: none;
125 +
  font-size: 1rem;
126 +
}
127 +
.tab:hover {
128 +
  color: var(--fg);
129 +
  text-decoration: none;
130 +
}
131 +
.tab.active {
132 +
  color: var(--fg);
133 +
  border-top: 1px solid var(--border);
134 +
  border-left: 1px solid var(--border);
135 +
  border-right: 1px solid var(--border);
136 +
  border-radius: 4px 4px 0 0;
137 +
  background: var(--bg);
138 +
}
139 +
.tab.tab-mono {
140 +
  font-family: var(--mono);
141 +
  font-size: 1rem;
142 +
}
143 +
.repo-nav .tab:first-of-type { margin-left: auto; }
144 +
145 +
/* --- Headings --- */
146 +
147 +
h2 { font-family: var(--sans); font-weight: 500; font-size: 1rem; margin-bottom: 0.75rem; color: var(--fg-headers); }
148 +
h3 {
149 +
  font-family: var(--sans);
150 +
  font-weight: 600;
151 +
  font-size: 1rem;
152 +
  margin: 2rem 0 0.5rem;
153 +
  color: var(--fg);
154 +
}
155 +
h3:first-child { margin-top: 0; }
156 +
157 +
.empty { color: var(--fg-dim); font-style: italic; padding: 0.5rem 0; }
158 +
159 +
/* --- Tables --- */
160 +
161 +
table {
162 +
  width: 100%;
163 +
  border-collapse: collapse;
164 +
  margin-bottom: 1rem;
165 +
}
166 +
th, td {
167 +
  text-align: left;
168 +
  padding: 0.25rem 0.75rem;
169 +
  border-bottom: 1px solid var(--border);
170 +
}
171 +
th {
172 +
  font-size: 1rem;
173 +
  padding-top: 0.25rem;
174 +
  color: var(--fg-dim);
175 +
  font-weight: 600;
176 +
  border-bottom: none;
177 +
}
178 +
.repo-list tbody tr:hover,
179 +
.log-list tbody tr:hover,
180 +
.ref-list tbody tr:hover,
181 +
.diffstat-list tbody tr:hover { background: var(--bg-alt); }
182 +
.repo-list td:first-child { font-weight: 600; }
183 +
.repo-list td,
184 +
.log-list td,
185 +
.ref-list td { border-bottom: none; }
186 +
.repo-list th:first-child,
187 +
.repo-list td:first-child,
188 +
.log-list th:first-child,
189 +
.log-list td:first-child,
190 +
.ref-list th:first-child,
191 +
.ref-list td:first-child { padding-left: 0.5rem; }
192 +
.repo-list th:last-child,
193 +
.repo-list td:last-child,
194 +
.log-list th:last-child,
195 +
.log-list td:last-child,
196 +
.ref-list th:last-child,
197 +
.ref-list td:last-child { padding-right: 0.5rem; }
198 +
199 +
.hash { font-family: var(--mono); white-space: nowrap; }
200 +
.date { white-space: nowrap; color: var(--fg-dim); }
201 +
.author { color: var(--fg-dim); white-space: nowrap; }
202 +
.subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
203 +
.mode { font-family: var(--mono); font-size: 1rem; color: var(--fg-dim); white-space: nowrap; }
204 +
.size { font-family: var(--mono); font-size: 0.875rem; color: var(--fg-dim); text-align: right; white-space: nowrap; }
205 +
206 +
.clone-url {
207 +
  background: var(--bg-alt);
208 +
  border: 1px solid var(--border);
209 +
  padding: 0.5rem 0.75rem;
210 +
  border-radius: 4px;
211 +
  margin-bottom: 1rem;
212 +
  font-family: var(--mono);
213 +
  font-size: 1rem;
214 +
}
215 +
.clone-url label { font-family: var(--sans); font-weight: bold; margin-right: 0.5rem; }
216 +
217 +
/* --- Breadcrumb --- */
218 +
219 +
.breadcrumb {
220 +
  font-family: var(--mono);
221 +
  font-size: 1rem;
222 +
  margin-bottom: 1rem;
223 +
  color: var(--fg-dim);
224 +
}
225 +
226 +
/* --- File view / blob --- */
227 +
228 +
.file-view {
229 +
  border: 1px solid var(--border);
230 +
  border-radius: 4px;
231 +
}
232 +
.file-view .blob-view { border: none; border-radius: 0; }
233 +
.file-header {
234 +
  display: flex;
235 +
  gap: 0.75rem;
236 +
  align-items: baseline;
237 +
  padding: 0.5rem 0.75rem;
238 +
  border-bottom: 1px solid var(--border);
239 +
  font-size: 1rem;
240 +
}
241 +
.file-header .file-name { font-weight: 600; flex: 1; }
242 +
.file-header .file-meta { color: var(--fg-dim); font-size: 0.875rem; }
243 +
.file-header .file-action {
244 +
  border-left: 1px solid var(--border);
245 +
  padding-left: 0.75rem;
246 +
  align-self: stretch;
247 +
  margin: -0.5rem 0;
248 +
  display: flex;
249 +
  align-items: center;
250 +
}
251 +
.file-view pre {
252 +
  margin: 0;
253 +
  padding: 1rem;
254 +
  font-family: var(--mono);
255 +
  font-size: 1rem;
256 +
  line-height: 1.5;
257 +
  white-space: pre-wrap;
258 +
}
259 +
260 +
.blob-view {
261 +
  border: 1px solid var(--border);
262 +
  border-radius: 4px;
263 +
  overflow: auto hidden;
264 +
}
265 +
.blob-code {
266 +
  margin: 0;
267 +
  border: none;
268 +
  border-collapse: collapse;
269 +
  border-spacing: 0;
270 +
}
271 +
.blob-code td { border: none; padding: 0; vertical-align: top; line-height: 0; }
272 +
.blob-code tr:first-child td { padding-top: 0.5rem; }
273 +
.blob-code tr:last-child td { padding-bottom: 0.75rem; }
274 +
.blob-code pre { margin: 0; font-family: var(--mono); font-size: 1rem; line-height: 1.45; white-space: pre; display: inline; }
275 +
.line-num {
276 +
  width: 1%;
277 +
  min-width: 4rem;
278 +
  padding: 0 0.5rem;
279 +
  text-align: right;
280 +
  user-select: none;
281 +
  vertical-align: top;
282 +
}
283 +
.line-num a {
284 +
  color: var(--fg-dim);
285 +
  opacity: 0.4;
286 +
  font-family: var(--mono);
287 +
  font-size: 1rem;
288 +
  line-height: 1.45;
289 +
  display: inline-block;
290 +
}
291 +
.line-num a:hover { opacity: 1; text-decoration: none; }
292 +
.line-code { width: 100%; padding-left: 0.75rem; }
293 +
tr:target .line-num { background: var(--bg-alt); }
294 +
tr:target .line-code { background: var(--bg-alt); }
295 +
296 +
/* --- Commit view --- */
297 +
298 +
.commit-info { margin-bottom: 1.5rem; }
299 +
.commit-title {
300 +
  display: flex;
301 +
  align-items: baseline;
302 +
  gap: 1rem;
303 +
  margin-bottom: 0.75rem;
304 +
}
305 +
.commit-title .subject { font-family: var(--sans); font-weight: 600; font-size: 1.125rem; flex: 1; }
306 +
.commit-hash { font-family: var(--mono); font-size: 1rem; color: var(--fg-dim); white-space: nowrap; }
307 +
.commit-info .body { font-family: var(--mono); font-size: 1rem; margin-bottom: 0.75rem; white-space: pre-wrap; color: var(--fg-dim); }
308 +
.commit-subtitle {
309 +
  display: flex;
310 +
  align-items: baseline;
311 +
  font-size: 1rem;
312 +
  color: var(--fg-dim);
313 +
}
314 +
.commit-meta { flex: 1; }
315 +
.commit-parents { white-space: nowrap; }
316 +
.commit-parents code { font-family: var(--mono); }
317 +
318 +
/* --- Diffstat --- */
319 +
320 +
.diffstat { margin-bottom: 1.5rem; }
321 +
.diffstat-summary {
322 +
  font-size: 1rem;
323 +
  color: var(--fg-dim);
324 +
  margin-bottom: 0.75rem;
325 +
}
326 +
.diffstat-list {
327 +
  font-size: 1rem;
328 +
  width: auto;
329 +
  border: 1px solid var(--border);
330 +
  border-radius: 4px;
331 +
}
332 +
.diffstat-list td { border: none; padding: 0.25rem 0.75rem; }
333 +
.diffstat-list td:first-child { padding-left: 0.75rem; padding-right: 0.5rem; width: 1%; }
334 +
.diffstat-list .name { font-family: var(--mono); }
335 +
.diffstat-list .stats {
336 +
  white-space: nowrap;
337 +
  font-family: var(--mono);
338 +
  text-align: right;
339 +
  width: 1%;
340 +
  padding-right: 0.75rem;
341 +
}
342 +
.diffstat-bar {
343 +
  display: inline-flex;
344 +
  gap: 1px;
345 +
  vertical-align: middle;
346 +
  margin-left: 0.5rem;
347 +
}
348 +
.diffstat-bar span {
349 +
  display: inline-block;
350 +
  width: 8px;
351 +
  height: 8px;
352 +
  border-radius: 1px;
353 +
}
354 +
.diff-header-stats { margin-left: auto; font-family: var(--mono); font-size: 1rem; white-space: nowrap; color: var(--fg-dim); }
355 +
.diffstat-bar .bar-add { background: var(--add-fg); }
356 +
.diffstat-bar .bar-del { background: var(--del-fg); }
357 +
.diffstat-bar .bar-neutral { background: #bbb; }
358 +
359 +
/* --- Status badges --- */
360 +
361 +
.status {
362 +
  font-family: var(--mono);
363 +
  font-size: 1rem;
364 +
  font-weight: 600;
365 +
  display: inline-block;
366 +
  padding: 0.125rem 0.5rem;
367 +
  border-radius: 4px;
368 +
  letter-spacing: 0.03em;
369 +
  border: 1px solid;
370 +
}
371 +
.status-A { color: var(--add-fg); border-color: var(--add-fg); }
372 +
.status-D { color: var(--del-fg); border-color: var(--del-fg); }
373 +
.status-M { color: #b08800; border-color: #b08800; }
374 +
.status-R { color: #6f42c1; border-color: #6f42c1; }
375 +
376 +
.add { color: var(--add-fg); }
377 +
.del { color: var(--del-fg); }
378 +
379 +
/* --- Diff files --- */
380 +
381 +
.diff-file {
382 +
  border: 1px solid var(--border);
383 +
  border-radius: 4px;
384 +
  margin-bottom: 1rem;
385 +
  overflow-x: auto;
386 +
}
387 +
.diff-header {
388 +
  display: flex;
389 +
  align-items: center;
390 +
  gap: 0.5rem;
391 +
  background: none;
392 +
  border-bottom: 1px solid var(--border);
393 +
  padding: 0.5rem 0.75rem;
394 +
  font-size: 1rem;
395 +
  font-weight: 500;
396 +
  color: var(--fg-headers);
397 +
}
398 +
.diff-header .status { font-weight: 600; font-size: 0.875rem; }
399 +
.diff-hunk {
400 +
  margin: 0;
401 +
  border: none;
402 +
  width: 100%;
403 +
}
404 +
.diff-hunk td { border: none; padding: 0; vertical-align: top; }
405 +
td.diff-num {
406 +
  width: 1%;
407 +
  min-width: 3rem;
408 +
  padding: 0 0.25rem 0 1rem;
409 +
  text-align: right;
410 +
  font-family: var(--mono);
411 +
  font-size: 1rem;
412 +
  line-height: 1.45;
413 +
  color: var(--fg-dim);
414 +
  opacity: 0.5;
415 +
  user-select: none;
416 +
}
417 +
td.diff-marker {
418 +
  padding: 0 0.25rem 0 1rem;
419 +
  font-family: var(--mono);
420 +
  font-size: 1rem;
421 +
  line-height: 1.45;
422 +
  user-select: none;
423 +
}
424 +
.diff-add .diff-num,
425 +
.diff-add .diff-marker { color: var(--add-fg); opacity: 0.7; }
426 +
.diff-del .diff-num,
427 +
.diff-del .diff-marker { color: var(--del-fg); opacity: 0.7; }
428 +
.diff-context .diff-marker { color: transparent; }
429 +
.diff-hunk pre { margin: 0; font-family: var(--mono); font-size: 1rem; line-height: 1.45; white-space: pre; padding: 0 0.75rem; }
430 +
.diff-hunk tr:first-child td { padding-top: 0.5rem; }
431 +
.diff-hunk tr:last-child td { padding-bottom: 0.5rem; }
432 +
.diff-add td { background: var(--add-bg); }
433 +
.diff-del td { background: var(--del-bg); }
434 +
.diff-sep {
435 +
  margin: 0.25rem 0;
436 +
  border-top: 1px dashed var(--border);
437 +
}
438 +
439 +
/* --- Pagination --- */
440 +
441 +
.pagination {
442 +
  display: flex;
443 +
  justify-content: center;
444 +
  padding: 0.75rem 0 0.25rem;
445 +
  font-size: 1rem;
446 +
}
447 +
448 +
.pagination a {
449 +
  padding: 0.35rem 0.75rem;
450 +
  border: 1px solid var(--border);
451 +
  color: var(--fg-dim);
452 +
  text-decoration: none;
453 +
}
454 +
455 +
.pagination a + a {
456 +
  border-left: none;
457 +
}
458 +
459 +
.pagination a:first-child {
460 +
  border-radius: 4px 0 0 4px;
461 +
}
462 +
463 +
.pagination a:last-child {
464 +
  border-radius: 0 4px 4px 0;
465 +
}
466 +
467 +
.pagination a:only-child {
468 +
  border-radius: 4px;
469 +
}
470 +
471 +
.pagination a:hover {
472 +
  color: var(--fg);
473 +
}
474 +
475 +
/* --- Last commit bar --- */
476 +
477 +
.last-commit {
478 +
  display: flex;
479 +
  align-items: center;
480 +
  gap: 0.75rem;
481 +
  border: 1px solid var(--border);
482 +
  border-radius: 4px;
483 +
  padding: 0.25rem 0.75rem;
484 +
  font-size: 0.875rem;
485 +
  overflow: hidden;
486 +
}
487 +
.last-commit .date {
488 +
  margin-left: -0.5rem;
489 +
}
490 +
491 +
/* --- Repo home (tree + content) --- */
492 +
493 +
.repo-home {
494 +
  display: grid;
495 +
  grid-template-columns: 280px 1fr;
496 +
  gap: 1.5rem;
497 +
  align-items: start;
498 +
}
499 +
/* --- Branch header --- */
500 +
501 +
.branch-header {
502 +
  display: flex;
503 +
  align-items: stretch;
504 +
  margin-left: auto;
505 +
  margin-bottom: 0.25rem;
506 +
}
507 +
.branch-header .branch-selector summary {
508 +
  border-radius: 4px 0 0 4px;
509 +
  border-right: none;
510 +
}
511 +
.branch-header .last-commit {
512 +
  border-radius: 0 4px 4px 0;
513 +
}
514 +
515 +
/* --- View nav (Files / Log) --- */
516 +
517 +
.view-nav {
518 +
  margin: 0 0 1rem;
519 +
  padding: 0.5rem 0;
520 +
  display: flex;
521 +
  align-items: center;
522 +
}
523 +
.view-nav .last-commit {
524 +
  margin-left: auto;
525 +
}
526 +
.view-nav .branch-selector {
527 +
  margin-right: 1.5rem;
528 +
}
529 +
530 +
/* --- Branch selector --- */
531 +
532 +
.branch-selector {
533 +
  position: relative;
534 +
}
535 +
.branch-selector summary {
536 +
  display: flex;
537 +
  align-items: center;
538 +
  gap: 0.5rem;
539 +
  border: 1px solid var(--border);
540 +
  border-radius: 4px;
541 +
  padding: 0.25rem 0.75rem;
542 +
  font-size: 0.875rem;
543 +
  font-family: var(--mono);
544 +
  cursor: pointer;
545 +
  list-style: none;
546 +
}
547 +
.branch-selector summary:hover {
548 +
  background: var(--bg-alt);
549 +
}
550 +
.branch-selector summary::-webkit-details-marker { display: none; }
551 +
.branch-selector summary::after {
552 +
  content: "";
553 +
  flex-shrink: 0;
554 +
  margin-left: auto;
555 +
  width: 0;
556 +
  height: 0;
557 +
  border-left: 4px solid transparent;
558 +
  border-right: 4px solid transparent;
559 +
  border-top: 5px solid var(--fg-dim);
560 +
}
561 +
.branch-selector[open] summary::after {
562 +
  border-top: none;
563 +
  border-bottom: 5px solid var(--fg-dim);
564 +
}
565 +
.branch-selector ul {
566 +
  position: absolute;
567 +
  z-index: 10;
568 +
  list-style: none;
569 +
  background: var(--bg);
570 +
  border: 1px solid var(--border);
571 +
  border-radius: 4px;
572 +
  margin-top: 0.25rem;
573 +
  padding: 0.25rem 0;
574 +
  min-width: 100%;
575 +
  max-height: 300px;
576 +
  overflow-y: auto;
577 +
}
578 +
.branch-selector li a {
579 +
  display: block;
580 +
  padding: 0.25rem 0.75rem;
581 +
  font-size: 0.875rem;
582 +
  font-family: var(--mono);
583 +
  text-decoration: none;
584 +
  white-space: nowrap;
585 +
}
586 +
.branch-selector li a:hover {
587 +
  background: var(--bg-alt);
588 +
  text-decoration: none;
589 +
}
590 +
.branch-selector li.active a {
591 +
  background: var(--bg-alt);
592 +
  font-weight: 600;
593 +
}
594 +
595 +
/* --- File tree --- */
596 +
597 +
.tree-entry {
598 +
  display: flex;
599 +
  align-items: baseline;
600 +
  padding: 0.25rem 0.5rem;
601 +
  border-radius: 4px;
602 +
  color: var(--link);
603 +
  text-decoration: none;
604 +
}
605 +
.tree-entry:hover { text-decoration: none; background: var(--bg-alt); }
606 +
.tree-entry.active { background: var(--bg-alt); }
607 +
.tree-entry.active .name { font-weight: 600; }
608 +
.file-tree .name { white-space: nowrap; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
609 +
.file-tree .size { white-space: nowrap; margin-left: 0.5rem; }
610 +
611 +
.content-view { min-width: 0; }
612 +
.content-view h2 { font-size: 1rem; margin-bottom: 0.5rem; }
613 +
614 +
/* --- Error page --- */
615 +
616 +
.error-page {
617 +
  text-align: center;
618 +
  padding: 4rem 1rem;
619 +
}
620 +
.error-code {
621 +
  font-family: var(--mono);
622 +
  font-size: 1.125rem;
623 +
  font-weight: 600;
624 +
  color: var(--fg-dim);
625 +
  display: block;
626 +
  margin-bottom: 0.5rem;
627 +
}
628 +
.error-message {
629 +
  color: var(--fg);
630 +
  margin-bottom: 0.5rem;
631 +
}
632 +
.error-path {
633 +
  display: block;
634 +
  color: var(--fg-dim);
635 +
  margin-bottom: 1.5rem;
636 +
}
637 +
638 +
/* --- Footer --- */
639 +
640 +
footer {
641 +
  margin-top: 1.5rem;
642 +
  padding: 0.75rem 0 1.5rem;
643 +
  border-top: 1px solid var(--border);
644 +
  font-size: 1rem;
645 +
  color: var(--fg-dim);
646 +
}
647 +
648 +
/* --- Syntax highlighting (Radiance) --- */
649 +
650 +
.language-radiance .comment { color: var(--color-dimmer); font-style: italic; }
651 +
.language-radiance .builtin { color: var(--color-secondary); }
652 +
.language-radiance .op { color: var(--color-dim); }
653 +
.language-radiance .access,
654 +
.language-radiance .delim { color: var(--color-dim); }
655 +
.language-radiance .keyword { color: var(--color-dim); font-weight: 600; }
656 +
.language-radiance .type { color: var(--color-highlight-dim); font-weight: 600; }
657 +
.language-radiance .ref { color: var(--color-dim); }
658 +
.language-radiance .string { color: var(--color-secondary); }
659 +
.language-radiance .number { color: var(--color-highlight); }
660 +
661 +
/* --- Syntax highlighting (Radiance IL) --- */
662 +
663 +
.language-ril .comment { color: var(--color-dimmer); font-style: italic; }
664 +
.language-ril .keyword,
665 +
.language-ril .instr { color: var(--color-dim); font-weight: 600; }
666 +
.language-ril .type { color: var(--color-highlight-dim); font-weight: 600; }
667 +
.language-ril .delim { color: var(--color-dim); }
668 +
.language-ril .number { color: var(--color-highlight); }
669 +
.language-ril .string { color: var(--color-secondary); }
670 +
.language-ril .reg { color: var(--color-secondary); }
671 +
.language-ril .symbol { color: var(--color-highlight-dim); }
672 +
.language-ril .label { color: var(--color-highlight-dim); font-style: italic; }
template.go added +220 -0
1 +
package main
2 +
3 +
import (
4 +
	"embed"
5 +
	"fmt"
6 +
	"html/template"
7 +
	"io"
8 +
	"path"
9 +
	"strings"
10 +
	"time"
11 +
)
12 +
13 +
//go:embed templates/*.html
14 +
var templateFS embed.FS
15 +
16 +
//go:embed static/style.css
17 +
var cssContent []byte
18 +
19 +
//go:embed static/radiant.svg
20 +
var logoContent []byte
21 +
22 +
//go:embed static/fonts/*
23 +
var fontsFS embed.FS
24 +
25 +
//go:embed static/js/hirad.js
26 +
var hiradJS []byte
27 +
28 +
//go:embed static/js/hiril.js
29 +
var hirilJS []byte
30 +
31 +
type templateSet struct {
32 +
	templates map[string]*template.Template
33 +
}
34 +
35 +
var funcMap = template.FuncMap{
36 +
	"shortHash": func(h string) string {
37 +
		if len(h) > 8 {
38 +
			return h[:8]
39 +
		}
40 +
		return h
41 +
	},
42 +
	"timeAgo": func(t time.Time) template.HTML {
43 +
		d := time.Since(t)
44 +
		var ago string
45 +
		switch {
46 +
		case d < time.Minute:
47 +
			ago = "just now"
48 +
		case d < time.Hour:
49 +
			m := int(d.Minutes())
50 +
			if m == 1 {
51 +
				ago = "1 minute"
52 +
			} else {
53 +
				ago = fmt.Sprintf("%d minutes", m)
54 +
			}
55 +
		case d < 24*time.Hour:
56 +
			h := int(d.Hours())
57 +
			if h == 1 {
58 +
				ago = "1 hour"
59 +
			} else {
60 +
				ago = fmt.Sprintf("%d hours", h)
61 +
			}
62 +
		case d < 30*24*time.Hour:
63 +
			days := int(d.Hours() / 24)
64 +
			if days == 1 {
65 +
				ago = "1 day"
66 +
			} else {
67 +
				ago = fmt.Sprintf("%d days", days)
68 +
			}
69 +
		case d < 365*24*time.Hour:
70 +
			months := int(d.Hours() / 24 / 30)
71 +
			if months == 1 {
72 +
				ago = "1 month"
73 +
			} else {
74 +
				ago = fmt.Sprintf("%d months", months)
75 +
			}
76 +
		default:
77 +
			years := int(d.Hours() / 24 / 365)
78 +
			if years == 1 {
79 +
				ago = "1 year"
80 +
			} else {
81 +
				ago = fmt.Sprintf("%d years", years)
82 +
			}
83 +
		}
84 +
		full := t.Format("2006-01-02 15:04:05 -0700")
85 +
		return template.HTML(fmt.Sprintf(`<time title="%s">%s</time>`, full, ago))
86 +
	},
87 +
	"formatDate": func(t time.Time) string {
88 +
		return t.Format("2006-01-02 15:04:05 -0700")
89 +
	},
90 +
	"add": func(a, b int) int { return a + b },
91 +
	"diffFileName": func(f DiffFile) string {
92 +
		if f.NewName != "" {
93 +
			return f.NewName
94 +
		}
95 +
		return f.OldName
96 +
	},
97 +
	"statusLabel": func(s string) string {
98 +
		switch s {
99 +
		case "A":
100 +
			return "added"
101 +
		case "D":
102 +
			return "deleted"
103 +
		case "M":
104 +
			return "modified"
105 +
		case "R":
106 +
			return "renamed"
107 +
		}
108 +
		return s
109 +
	},
110 +
	"formatSize": func(size int64) string {
111 +
		switch {
112 +
		case size < 1024:
113 +
			return fmt.Sprintf("%d B", size)
114 +
		case size < 1024*1024:
115 +
			return fmt.Sprintf("%.1f KiB", float64(size)/1024)
116 +
		default:
117 +
			return fmt.Sprintf("%.1f MiB", float64(size)/(1024*1024))
118 +
		}
119 +
	},
120 +
	"diffBar": func(added, deleted int) template.HTML {
121 +
		total := added + deleted
122 +
		if total == 0 {
123 +
			return ""
124 +
		}
125 +
		const maxBlocks = 5
126 +
		blocks := maxBlocks
127 +
		if total < blocks {
128 +
			blocks = total
129 +
		}
130 +
		addBlocks := 0
131 +
		if total > 0 {
132 +
			addBlocks = (added * blocks + total - 1) / total
133 +
		}
134 +
		if addBlocks > blocks {
135 +
			addBlocks = blocks
136 +
		}
137 +
		delBlocks := blocks - addBlocks
138 +
		var b strings.Builder
139 +
		b.WriteString(`<span class="diffstat-bar">`)
140 +
		for i := 0; i < addBlocks; i++ {
141 +
			b.WriteString(`<span class="bar-add"></span>`)
142 +
		}
143 +
		for i := 0; i < delBlocks; i++ {
144 +
			b.WriteString(`<span class="bar-del"></span>`)
145 +
		}
146 +
		for i := addBlocks + delBlocks; i < maxBlocks; i++ {
147 +
			b.WriteString(`<span class="bar-neutral"></span>`)
148 +
		}
149 +
		b.WriteString(`</span>`)
150 +
		return template.HTML(b.String())
151 +
	},
152 +
	"langClass": func(name string) string {
153 +
		ext := path.Ext(name)
154 +
		switch ext {
155 +
		case ".rad":
156 +
			return "language-radiance"
157 +
		case ".ril":
158 +
			return "language-ril"
159 +
		}
160 +
		return ""
161 +
	},
162 +
	"parentPath": func(p string) string {
163 +
		dir := path.Dir(p)
164 +
		if dir == "." {
165 +
			return ""
166 +
		}
167 +
		return dir
168 +
	},
169 +
	"indent": func(depth int) string {
170 +
		if depth == 0 {
171 +
			return ""
172 +
		}
173 +
		return fmt.Sprintf("padding-left: %grem", float64(depth)*1.2)
174 +
	},
175 +
}
176 +
177 +
func loadTemplates() (*templateSet, error) {
178 +
	layoutContent, err := templateFS.ReadFile("templates/layout.html")
179 +
	if err != nil {
180 +
		return nil, fmt.Errorf("read layout: %w", err)
181 +
	}
182 +
183 +
	pages := []string{"index", "home", "log", "commit", "refs", "error"}
184 +
	ts := &templateSet{
185 +
		templates: make(map[string]*template.Template, len(pages)),
186 +
	}
187 +
188 +
	for _, page := range pages {
189 +
		t := template.New("layout").Funcs(funcMap)
190 +
		t, err = t.Parse(string(layoutContent))
191 +
		if err != nil {
192 +
			return nil, fmt.Errorf("parse layout: %w", err)
193 +
		}
194 +
195 +
		pageContent, err := templateFS.ReadFile(fmt.Sprintf("templates/%s.html", page))
196 +
		if err != nil {
197 +
			return nil, fmt.Errorf("read %s: %w", page, err)
198 +
		}
199 +
200 +
		t, err = t.Parse(string(pageContent))
201 +
		if err != nil {
202 +
			return nil, fmt.Errorf("parse %s: %w", page, err)
203 +
		}
204 +
205 +
		ts.templates[page] = t
206 +
	}
207 +
208 +
	return ts, nil
209 +
}
210 +
211 +
func (ts *templateSet) render(w io.Writer, name string, data any) {
212 +
	t, ok := ts.templates[name]
213 +
	if !ok {
214 +
		fmt.Fprintf(w, "template %q not found", name)
215 +
		return
216 +
	}
217 +
	if err := t.Execute(w, data); err != nil {
218 +
		fmt.Fprintf(w, "template error: %v", err)
219 +
	}
220 +
}
templates/commit.html added +46 -0
1 +
{{define "content"}}
2 +
<div class="commit-info">
3 +
  <div class="commit-title">
4 +
    <p class="subject">{{.Data.Commit.Subject}}</p>
5 +
    <code class="commit-hash">{{.Data.Commit.Hash}}</code>
6 +
  </div>
7 +
  {{if .Data.Commit.Body}}<pre class="body">{{.Data.Commit.Body}}</pre>{{end}}
8 +
  <div class="commit-subtitle">
9 +
    <span class="commit-meta">{{.Data.Commit.Author}} committed {{timeAgo .Data.Commit.AuthorDate}} ago</span>
10 +
    {{if .Data.Commit.Parents}}<span class="commit-parents">{{len .Data.Commit.Parents}} parent{{if ne (len .Data.Commit.Parents) 1}}s{{end}} {{range $i, $p := .Data.Commit.Parents}}{{if $i}} {{end}}<a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{$p}}"><code>{{shortHash $p}}</code></a>{{end}}</span>{{end}}
11 +
  </div>
12 +
</div>
13 +
14 +
{{range $file := .Data.Files}}
15 +
<div class="diff-file">
16 +
  <div class="diff-header">
17 +
    {{if eq .Status "R"}}
18 +
      <span>{{.OldName}} &rarr; {{.NewName}}</span>
19 +
    {{else}}
20 +
      <span>{{diffFileName .}}</span>
21 +
    {{end}}
22 +
    {{if ne .Status "M"}}<span class="status status-{{.Status}}">{{statusLabel .Status}}</span>{{end}}
23 +
    <span class="diff-header-stats"><span class="add">+{{.Stats.Added}}</span> <span class="del">-{{.Stats.Deleted}}</span> {{diffBar .Stats.Added .Stats.Deleted}}</span>
24 +
  </div>
25 +
  {{if .IsBinary}}
26 +
  <p class="empty">Binary file changed.</p>
27 +
  {{else}}
28 +
  {{range $i, $hunk := .Hunks}}
29 +
  {{if $i}}<div class="diff-sep"></div>{{end}}
30 +
  <table class="diff-hunk">
31 +
  <tbody>
32 +
  {{range .Lines}}
33 +
    <tr class="diff-line diff-{{.Type}}">
34 +
      <td class="diff-num">{{if .OldNum}}{{.OldNum}}{{end}}</td>
35 +
      <td class="diff-num">{{if .NewNum}}{{.NewNum}}{{end}}</td>
36 +
      <td class="diff-marker">{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}}</td>
37 +
      <td class="line-code"><pre{{with langClass (diffFileName $file)}} class="{{.}}"{{end}}>{{.Content}}</pre></td>
38 +
    </tr>
39 +
  {{end}}
40 +
  </tbody>
41 +
  </table>
42 +
  {{end}}
43 +
  {{end}}
44 +
</div>
45 +
{{end}}
46 +
{{end}}
templates/error.html added +8 -0
1 +
{{define "content"}}
2 +
<div class="error-page">
3 +
  <span class="error-code">{{.Data.Code}}</span>
4 +
  <p class="error-message">{{.Data.Message}}</p>
5 +
  <code class="error-path">{{.Data.Path}}</code>
6 +
  <p><a href="{{.BaseURL}}/">&larr; Back to index</a></p>
7 +
</div>
8 +
{{end}}
templates/home.html added +72 -0
1 +
{{define "content"}}
2 +
{{if .Data.IsEmpty}}
3 +
<p class="empty">This repository is empty.</p>
4 +
{{else}}
5 +
6 +
<nav class="view-nav">
7 +
  <details class="branch-selector">
8 +
    <summary>{{.Data.DefaultRef}}</summary>
9 +
    <ul>
10 +
      {{range .Data.Branches}}
11 +
      <li{{if eq .Name $.Data.DefaultRef}} class="active"{{end}}><a href="{{$.BaseURL}}/{{$.Repo}}/tree/{{.Name}}/">{{.Name}}</a></li>
12 +
      {{end}}
13 +
    </ul>
14 +
  </details>
15 +
  {{if .Data.LastCommit}}
16 +
  <div class="last-commit">
17 +
    <span class="hash"><a href="{{.BaseURL}}/{{.Repo}}/commit/{{.Data.LastCommit.Hash}}">{{.Data.LastCommit.ShortHash}}</a></span>
18 +
    <span class="subject">{{.Data.LastCommit.Subject}}</span>
19 +
    <span class="author">{{.Data.LastCommit.Author}}</span>
20 +
    <span class="date">{{timeAgo .Data.LastCommit.AuthorDate}} ago</span>
21 +
  </div>
22 +
  {{end}}
23 +
</nav>
24 +
25 +
<div class="repo-home">
26 +
  <div class="file-tree">
27 +
    {{range .Data.Tree}}
28 +
    <a class="tree-entry{{if .IsActive}} active{{end}}" href="{{$.BaseURL}}/{{$.Repo}}/tree/{{$.Data.DefaultRef}}/{{if and .IsDir .IsOpen}}{{parentPath .Path}}{{else}}{{.Path}}{{end}}" style="{{indent .Depth}}">
29 +
      <span class="name">{{.Name}}{{if .IsDir}}/{{end}}</span>
30 +
      <span class="size">{{if not .IsDir}}{{formatSize .Size}}{{end}}</span>
31 +
    </a>
32 +
    {{end}}
33 +
  </div>
34 +
35 +
  <div class="content-view">
36 +
    {{if .Data.ActiveBlob}}
37 +
    <div class="file-view">
38 +
      <div class="file-header">
39 +
        <span class="file-name">{{.Data.ActivePath}}</span>
40 +
        <span class="file-meta">{{formatSize .Data.ActiveBlob.Size}}</span>
41 +
        <a class="file-action" href="{{.BaseURL}}/{{.Repo}}/raw/{{.Data.DefaultRef}}/{{.Data.ActivePath}}">raw</a>
42 +
      </div>
43 +
      {{if .Data.ActiveBlob.IsBinary}}
44 +
      <p class="empty">Binary file. <a href="{{.BaseURL}}/{{.Repo}}/raw/{{.Data.DefaultRef}}/{{.Data.ActivePath}}">Download</a></p>
45 +
      {{else}}
46 +
      <div class="blob-view">
47 +
      <table class="blob-code">
48 +
      <tbody>
49 +
      {{range $i, $line := .Data.ActiveBlob.Lines}}
50 +
      <tr id="L{{add $i 1}}">
51 +
        <td class="line-num"><a href="#L{{add $i 1}}">{{add $i 1}}</a></td>
52 +
        <td class="line-code"><pre{{with langClass $.Data.ActiveBlob.Name}} class="{{.}}"{{end}}>{{$line}}</pre></td>
53 +
      </tr>
54 +
      {{end}}
55 +
      </tbody>
56 +
      </table>
57 +
      </div>
58 +
      {{end}}
59 +
    </div>
60 +
    {{else if .Data.Readme}}
61 +
    <div class="file-view">
62 +
      <div class="file-header">
63 +
        <span class="file-name">{{.Data.Readme.Name}}</span>
64 +
      </div>
65 +
      <pre>{{.Data.Readme.Content}}</pre>
66 +
    </div>
67 +
    {{end}}
68 +
  </div>
69 +
</div>
70 +
71 +
{{end}}
72 +
{{end}}
templates/index.html added +24 -0
1 +
{{define "content"}}
2 +
{{if .Data.IsEmpty}}
3 +
<p class="empty">No repositories found.</p>
4 +
{{else}}
5 +
<table class="repo-list">
6 +
<thead>
7 +
  <tr>
8 +
    <th>Repository</th>
9 +
    <th>Description</th>
10 +
    <th>Idle</th>
11 +
  </tr>
12 +
</thead>
13 +
<tbody>
14 +
{{range .Data.Repos}}
15 +
  <tr>
16 +
    <td><a href="{{$.BaseURL}}/{{.Name}}/">{{.Name}}</a></td>
17 +
    <td>{{.Description}}</td>
18 +
    <td>{{if not .LastUpdated.IsZero}}{{timeAgo .LastUpdated}}{{end}}</td>
19 +
  </tr>
20 +
{{end}}
21 +
</tbody>
22 +
</table>
23 +
{{end}}
24 +
{{end}}
templates/layout.html added +37 -0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
<head>
4 +
<meta charset="utf-8">
5 +
<meta name="viewport" content="width=device-width, initial-scale=1">
6 +
<title>{{if .Repo}}{{.Repo}} · {{if eq .Section "home"}}{{.Description}}{{else}}{{.Section}}{{end}}{{else}}{{.SiteTitle}}{{end}}</title>
7 +
<link rel="stylesheet" href="{{.BaseURL}}/style.css">
8 +
</head>
9 +
<body>
10 +
<div class="container">
11 +
{{if not .Repo}}
12 +
<nav class="repo-nav">
13 +
  <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/">{{.SiteTitle}}</a></span>
14 +
</nav>
15 +
{{end}}
16 +
{{if .Repo}}
17 +
<nav class="repo-nav">
18 +
  <span class="repo-name"><a href="{{.BaseURL}}/" class="logo-link"><img class="logo" src="{{.BaseURL}}/radiant.svg" alt="" width="16" height="16"></a><a href="{{.BaseURL}}/{{.Repo}}/">{{.Repo}}</a>{{if .Description}}<span class="repo-desc">{{.Description}}</span>{{end}}</span>
19 +
  <a href="{{.BaseURL}}/{{.Repo}}/" class="tab{{if eq .Section "home"}} active{{end}}">home</a>
20 +
  <a href="{{.BaseURL}}/{{.Repo}}/log/{{.Ref}}" class="tab{{if eq .Section "log"}} active{{end}}">log</a>
21 +
  <a href="{{.BaseURL}}/{{.Repo}}/refs" class="tab{{if eq .Section "refs"}} active{{end}}">refs</a>
22 +
  {{if .CommitHash}}<a href="{{.BaseURL}}/{{.Repo}}/commit/{{.CommitHash}}" class="tab tab-mono active">{{shortHash .CommitHash}}</a>{{end}}
23 +
</nav>
24 +
{{end}}
25 +
<main>
26 +
{{template "content" .}}
27 +
</main>
28 +
{{if not .Repo}}
29 +
<footer>
30 +
  <p>&copy; 2026 Radiant Computer</p>
31 +
</footer>
32 +
{{end}}
33 +
</div>
34 +
<script src="{{.BaseURL}}/js/hirad.js"></script>
35 +
<script src="{{.BaseURL}}/js/hiril.js"></script>
36 +
</body>
37 +
</html>
templates/log.html added +50 -0
1 +
{{define "content"}}
2 +
<nav class="view-nav">
3 +
  <details class="branch-selector">
4 +
    <summary>{{.Data.Ref}}</summary>
5 +
    <ul>
6 +
      {{range .Data.Branches}}
7 +
      <li{{if eq .Name $.Data.Ref}} class="active"{{end}}><a href="{{$.BaseURL}}/{{$.Repo}}/log/{{.Name}}">{{.Name}}</a></li>
8 +
      {{end}}
9 +
    </ul>
10 +
  </details>
11 +
  {{if .Data.LastCommit}}
12 +
  <div class="last-commit">
13 +
    <span class="hash"><a href="{{.BaseURL}}/{{.Repo}}/commit/{{.Data.LastCommit.Hash}}">{{.Data.LastCommit.ShortHash}}</a></span>
14 +
    <span class="subject">{{.Data.LastCommit.Subject}}</span>
15 +
    <span class="author">{{.Data.LastCommit.Author}}</span>
16 +
    <span class="date">{{timeAgo .Data.LastCommit.AuthorDate}}</span>
17 +
  </div>
18 +
    {{end}}
19 +
</nav>
20 +
21 +
{{if .Data.IsEmpty}}
22 +
<p class="empty">No commits found.</p>
23 +
{{else}}
24 +
<table class="log-list">
25 +
<thead>
26 +
  <tr>
27 +
    <th>Hash</th>
28 +
    <th>Subject</th>
29 +
    <th>Author</th>
30 +
    <th>Age</th>
31 +
  </tr>
32 +
</thead>
33 +
<tbody>
34 +
{{range .Data.Commits}}
35 +
  <tr>
36 +
    <td class="hash"><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.ShortHash}}</a></td>
37 +
    <td class="subject">{{.Subject}}</td>
38 +
    <td class="author">{{.Author}}</td>
39 +
    <td class="date">{{timeAgo .AuthorDate}}</td>
40 +
  </tr>
41 +
{{end}}
42 +
</tbody>
43 +
</table>
44 +
45 +
<div class="pagination">
46 +
  {{if .Data.HasPrev}}<a href="?page={{.Data.PrevPage}}">&larr; Newer</a>{{end}}
47 +
  {{if .Data.HasNext}}<a href="?page={{.Data.NextPage}}">Older &rarr;</a>{{end}}
48 +
</div>
49 +
{{end}}
50 +
{{end}}
templates/refs.html added +51 -0
1 +
{{define "content"}}
2 +
{{if .Data.Branches}}
3 +
<table class="ref-list">
4 +
<thead>
5 +
  <tr>
6 +
    <th>Branch</th>
7 +
    <th>Hash</th>
8 +
    <th>Subject</th>
9 +
    <th>Author</th>
10 +
    <th>Idle</th>
11 +
  </tr>
12 +
</thead>
13 +
<tbody>
14 +
{{range .Data.Branches}}
15 +
  <tr>
16 +
    <td><a href="{{$.BaseURL}}/{{$.Repo}}/log/{{.Name}}">{{.Name}}</a></td>
17 +
    <td class="hash"><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.ShortHash}}</a></td>
18 +
    <td class="subject">{{.Subject}}</td>
19 +
    <td class="author">{{.Author}}</td>
20 +
    <td class="date">{{timeAgo .Date}}</td>
21 +
  </tr>
22 +
{{end}}
23 +
</tbody>
24 +
</table>
25 +
{{end}}
26 +
27 +
{{if .Data.Tags}}
28 +
<table class="ref-list">
29 +
<thead>
30 +
  <tr>
31 +
    <th>Tag</th>
32 +
    <th>Hash</th>
33 +
    <th>Subject</th>
34 +
    <th>Author</th>
35 +
    <th>Idle</th>
36 +
  </tr>
37 +
</thead>
38 +
<tbody>
39 +
{{range .Data.Tags}}
40 +
  <tr>
41 +
    <td><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.Name}}</a></td>
42 +
    <td class="hash"><a href="{{$.BaseURL}}/{{$.Repo}}/commit/{{.Hash}}">{{.ShortHash}}</a></td>
43 +
    <td class="subject">{{.Subject}}</td>
44 +
    <td class="author">{{.Author}}</td>
45 +
    <td class="date">{{timeAgo .Date}}</td>
46 +
  </tr>
47 +
{{end}}
48 +
</tbody>
49 +
</table>
50 +
{{end}}
51 +
{{end}}
watch added +5 -0
1 +
#!/bin/sh
2 +
# Rebuild and restart on source changes.
3 +
# Requires `entr(1)`.
4 +
find . -name '*.go' -o -name '*.html' -o -name '*.css' \
5 +
  | entr -r go run . -scan-path ~/src/radiant -non-bare "$@"