fierj
PublicTiny personal git forge
b646e80eab445ee522ea37bf0bb4963ae1865c47
diff --git a/git.go b/git.go
index 56dcda9..ecc4db4 100644
--- a/git.go
+++ b/git.go
@@ -3,6 +3,7 @@ package main
import (
"bytes"
"fmt"
+ "log/slog"
"os/exec"
"path"
"path/filepath"
@@ -45,6 +46,7 @@ type Git struct {
var _ VCS = (*Git)(nil)
func (g *Git) cmd(args ...string) (string, error) {
+ slog.Info("git command", "dir", g.Dir, "args", args)
cmd := exec.Command("git", args...)
cmd.Dir = g.Dir
var stdout, stderr bytes.Buffer
diff --git a/main.go b/main.go
index 093219f..fb7a2c1 100644
--- a/main.go
+++ b/main.go
@@ -1,12 +1,88 @@
package main
import (
+ "context"
+ "embed"
+ "fmt"
"log/slog"
"net/http"
"os"
+ "os/signal"
"path"
+ "sort"
+ "strings"
+ "syscall"
+ "text/template"
+ "time"
)
+//go:embed templates/*.html
+var templateFS embed.FS
+
+var funcMap = template.FuncMap{
+ "pathJoin": func(parts ...string) string {
+ var nonEmpty []string
+ for _, p := range parts {
+ if p != "" {
+ nonEmpty = append(nonEmpty, p)
+ }
+ }
+ return strings.Join(nonEmpty, "/")
+ },
+ "shortHash": func(h string) string {
+ if len(h) > 8 {
+ return h[:8]
+ }
+ return h
+ },
+ "hasSuffix": strings.HasSuffix,
+ "timeAgo": func(ts string) string {
+ t, err := time.Parse(time.RFC3339, ts)
+ if err != nil {
+ return ts
+ }
+ d := time.Since(t)
+ switch {
+ case d < time.Minute:
+ return "just now"
+ case d < time.Hour:
+ m := int(d.Minutes())
+ if m == 1 {
+ return "1 minute ago"
+ }
+ return fmt.Sprintf("%d minutes ago", m)
+ case d < 24*time.Hour:
+ h := int(d.Hours())
+ if h == 1 {
+ return "1 hour ago"
+ }
+ return fmt.Sprintf("%d hours ago", h)
+ case d < 30*24*time.Hour:
+ days := int(d.Hours() / 24)
+ if days == 1 {
+ return "1 day ago"
+ }
+ return fmt.Sprintf("%d days ago", days)
+ case d < 365*24*time.Hour:
+ months := int(d.Hours() / 24 / 30)
+ if months == 1 {
+ return "1 month ago"
+ }
+ return fmt.Sprintf("%d months ago", months)
+ default:
+ return ts[:10]
+ }
+ },
+}
+
+func renderError(w http.ResponseWriter, tmpl *template.Template, code int, message string) {
+ w.WriteHeader(code)
+ tmpl.ExecuteTemplate(w, "error.html", map[string]any{
+ "Code": code,
+ "Message": message,
+ })
+}
+
func main() {
cfgPath := "config.json"
if len(os.Args) > 1 {
@@ -16,14 +92,91 @@ func main() {
os.MkdirAll(cfg.Dir, 0755)
- if err := InitRepo(&cfg, "demo"); err != nil {
- slog.Error("failed to init demo repo", "error", err)
+ tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
+
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ repoName := "demo.git"
+ ref := "main"
+ treePath := ""
+ type FileEntry struct {
+ TreeEntry
+ LastCommit *Commit
+ }
+ git := &Git{Dir: path.Join(cfg.Dir, repoName)}
+ if git.IsEmpty() {
+ tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
+ "Repo": repoName,
+ "Ref": ref,
+ "Path": "",
+ "Entries": []FileEntry{},
+ // "CloneURL": fmt.Sprintf("%s@%s:%s.git", cfg.SSHUser, cfg.Host, repoName),
+ // "Description": repoDescription(repoPath),
+ "Branches": []string{},
+ "Tags": []string{},
+ "Commits": 0,
+ "Empty": true,
+ })
+ return
+ }
+ entries, err := git.List(ref, treePath)
+ if err != nil {
+ renderError(w, tmpl, http.StatusInternalServerError, "failed to list tree: "+err.Error())
+ return
+ }
+ // directories first
+ sort.Slice(entries, func(i, j int) bool {
+ if entries[i].Type != entries[j].Type {
+ return entries[i].Type == "tree"
+ }
+ return entries[i].Name < entries[j].Name
+ })
+
+ fileEntries := []FileEntry{}
+ for _, e := range entries {
+ fp := e.Name
+ if treePath != "" {
+ fp = treePath + "/" + e.Name
+ }
+ fe := FileEntry{TreeEntry: e, LastCommit: git.LastCommit(ref, fp)}
+ fileEntries = append(fileEntries, fe)
+ }
+ readme := git.Readme(ref, treePath)
+ tmpl.ExecuteTemplate(w, "tree.html", map[string]any{
+ "Repo": repoName,
+ "Ref": ref,
+ "Path": treePath,
+ // "Breadcrumb": makeBreadcrumb(treePath),
+ "Entries": fileEntries,
+ // "CloneURL": fmt.Sprintf("%s@%s:%s.git", cfg.SSHUser, cfg.Host, repoName),
+ // "Description": repoDescription(repoPath),
+ // "Branches": listBranches(repoPath),
+ // "Tags": listTags(repoPath),
+ // "Commits": commitCount(repoPath, ref),
+ "Readme": readme,
+ "ActiveTab": "code",
+ })
+ })
+
+ srv := &http.Server{
+ Addr: cfg.Addr,
+ Handler: mux,
}
- g := &Git{Dir: path.Join(cfg.Dir, "demo")}
- slog.Info("repo", "empty", g.IsEmpty())
- slog.Info("Starting server", "addr", cfg.Addr)
- if err := http.ListenAndServe(cfg.Addr, nil); err != nil {
- slog.Error("Server exited", "error", err)
+ go func() {
+ sig := make(chan os.Signal, 1)
+ signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
+ <-sig
+ slog.Info("shutting down...")
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ srv.Shutdown(ctx)
+ }()
+
+ slog.Info("fierj started", "addr", cfg.Addr, "repos", cfg.Dir)
+ if err := srv.ListenAndServe(); err != http.ErrServerClosed {
+ slog.Error("failed to start server", "error", err)
}
+ slog.Info("stopped")
}
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000..b09548e
--- /dev/null
+++ b/templates/error.html
@@ -0,0 +1,9 @@
+{{template "head" .}}
+{{define "title"}}Error {{.Code}} - fierj{{end}}
+<div style="text-align:center;padding:3rem 1rem;">
+ <h1 style="font-size:3rem;font-weight:300;color:var(--text-secondary);margin-bottom:0.5rem;">{{.Code}}</h1>
+ <p style="font-size:1.1rem;color:var(--text-secondary);margin-bottom:1.5rem;">{{.Message}}</p>
+ <a href="/" class="btn" style="display:inline-flex;">← Back to repositories</a>
+</div>
+{{template "foot" .}}
+
diff --git a/templates/layout.html b/templates/layout.html
new file mode 100644
index 0000000..39ac605
--- /dev/null
+++ b/templates/layout.html
@@ -0,0 +1,920 @@
+{{define "head"}}
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><rect width='16' height='16' rx='3' fill='%2324292f'/><text x='8' y='12' font-size='11' text-anchor='middle' fill='white' font-family='sans-serif'>fj</text></svg>">
+ <title>{{block "title" .}}fierj{{end}}</title>
+ <style>
+ :root {
+ --font-mono: 'SF Mono', 'Menlo', 'Consolas', monospace;
+ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
+ --space-xs: 0.25rem;
+ --space-sm: 0.5rem;
+ --space-md: 1rem;
+ --space-lg: 1.5rem;
+ --space-xl: 2rem;
+ --radius: 6px;
+ --radius-sm: 4px;
+ --max-width: 980px;
+
+ --bg: #ffffff;
+ --bg-page: #f6f8fa;
+ --bg-subtle: #f6f8fa;
+ --bg-inset: #eff2f5;
+ --bg-topbar: #24292f;
+ --border: #d0d7de;
+ --border-subtle: #d8dee4;
+ --text: #1f2328;
+ --text-secondary: #656d76;
+ --text-tertiary: #8c959f;
+ --text-link: #0969da;
+ --text-topbar: #ffffff;
+ --accent: #0969da;
+ --accent-subtle: #ddf4ff;
+ --accent-hover: #0550ae;
+ --success: #1a7f37;
+ --danger: #cf222e;
+ --diff-add-bg: #ccffd8;
+ --diff-del-bg: #ffd7d5;
+ --diff-add: #1a7f37;
+ --diff-del: #cf222e;
+ --shadow: 0 1px 3px rgba(31, 35, 40, 0.04);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #0d1117;
+ --bg-page: #010409;
+ --bg-subtle: #161b22;
+ --bg-inset: #1c2128;
+ --bg-topbar: #161b22;
+ --border: #30363d;
+ --border-subtle: #21262d;
+ --text: #e6edf3;
+ --text-secondary: #8b949e;
+ --text-tertiary: #6e7681;
+ --text-link: #58a6ff;
+ --text-topbar: #f0f6fc;
+ --accent: #58a6ff;
+ --accent-subtle: #0c2d6b;
+ --accent-hover: #79c0ff;
+ --success: #3fb950;
+ --danger: #f85149;
+ --diff-add-bg: #0d2818;
+ --diff-del-bg: #3c1420;
+ --diff-add: #3fb950;
+ --diff-del: #f85149;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ }
+ }
+
+ [data-theme="dark"] {
+ --bg: #0d1117;
+ --bg-page: #010409;
+ --bg-subtle: #161b22;
+ --bg-inset: #1c2128;
+ --bg-topbar: #161b22;
+ --border: #30363d;
+ --border-subtle: #21262d;
+ --text: #e6edf3;
+ --text-secondary: #8b949e;
+ --text-tertiary: #6e7681;
+ --text-link: #58a6ff;
+ --text-topbar: #f0f6fc;
+ --accent: #58a6ff;
+ --accent-subtle: #0c2d6b;
+ --accent-hover: #79c0ff;
+ --success: #3fb950;
+ --danger: #f85149;
+ --diff-add-bg: #0d2818;
+ --diff-del-bg: #3c1420;
+ --diff-add: #3fb950;
+ --diff-del: #f85149;
+ --shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+ }
+
+ [data-theme="light"] {
+ --bg: #ffffff;
+ --bg-page: #f6f8fa;
+ --bg-subtle: #f6f8fa;
+ --bg-inset: #eff2f5;
+ --bg-topbar: #24292f;
+ --border: #d0d7de;
+ --border-subtle: #d8dee4;
+ --text: #1f2328;
+ --text-secondary: #656d76;
+ --text-tertiary: #8c959f;
+ --text-link: #0969da;
+ --text-topbar: #ffffff;
+ --accent: #0969da;
+ --accent-subtle: #ddf4ff;
+ --accent-hover: #0550ae;
+ --success: #1a7f37;
+ --danger: #cf222e;
+ --diff-add-bg: #ccffd8;
+ --diff-del-bg: #ffd7d5;
+ --diff-add: #1a7f37;
+ --diff-del: #cf222e;
+ --shadow: 0 1px 3px rgba(31, 35, 40, 0.04);
+ }
+
+ *,
+ *::before,
+ *::after {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+ }
+
+ body {
+ font-family: var(--font-sans);
+ font-size: 14px;
+ line-height: 1.5;
+ color: var(--text);
+ background: var(--bg-page);
+ }
+
+ a {
+ color: var(--text-link);
+ text-decoration: none;
+ }
+
+ a:hover {
+ text-decoration: underline;
+ }
+
+ /* Top bar */
+ .topbar {
+ background: var(--bg-topbar);
+ padding: var(--space-sm) var(--space-lg);
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ }
+
+ .topbar a,
+ .topbar .logo {
+ color: var(--text-topbar);
+ text-decoration: none;
+ }
+
+ .topbar .logo {
+ font-weight: 700;
+ font-size: 1rem;
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ }
+
+ .topbar .spacer {
+ flex: 1;
+ }
+
+ .topbar .nav-link {
+ font-size: 0.85rem;
+ padding: var(--space-xs) var(--space-sm);
+ border-radius: var(--radius-sm);
+ }
+
+ .topbar .nav-link:hover {
+ background: rgba(255, 255, 255, 0.1);
+ text-decoration: none;
+ }
+
+ .topbar button {
+ background: none;
+ border: none;
+ color: var(--text-topbar);
+ cursor: pointer;
+ padding: var(--space-xs);
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ }
+
+ .topbar button:hover {
+ background: rgba(255, 255, 255, 0.1);
+ }
+
+ /* Main container */
+ .container {
+ max-width: var(--max-width);
+ margin: 0 auto;
+ padding: var(--space-lg) var(--space-md);
+ }
+
+ /* Repo header */
+ .repo-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-xs);
+ }
+
+ .repo-header .repo-icon {
+ color: var(--text-secondary);
+ }
+
+ .repo-header h1 {
+ font-size: 1.25rem;
+ font-weight: 400;
+ margin: 0;
+ }
+
+ .repo-header h1 a {
+ font-weight: 600;
+ }
+
+ .repo-header .visibility {
+ font-size: 0.7rem;
+ border: 1px solid var(--border);
+ border-radius: 2em;
+ padding: 0.1rem 0.5rem;
+ color: var(--text-secondary);
+ font-weight: 500;
+ }
+
+ .repo-desc {
+ color: var(--text-secondary);
+ margin-bottom: var(--space-md);
+ font-size: 0.9rem;
+ }
+
+ /* Tabs (code/issues/prs) */
+ .repo-tabs {
+ display: flex;
+ gap: 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: var(--space-lg);
+ }
+
+ .repo-tabs a {
+ display: flex;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: var(--space-sm) var(--space-md);
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ border-bottom: 2px solid transparent;
+ margin-bottom: -1px;
+ }
+
+ .repo-tabs a:hover {
+ text-decoration: none;
+ color: var(--text);
+ }
+
+ .repo-tabs a.active {
+ color: var(--text);
+ font-weight: 600;
+ border-bottom-color: var(--accent);
+ }
+
+ .repo-tabs .icon {
+ width: 16px;
+ height: 16px;
+ }
+
+ .repo-tabs .count {
+ background: var(--bg-inset);
+ border-radius: 2em;
+ padding: 0 0.5rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ min-width: 1.2rem;
+ text-align: center;
+ }
+
+ /* Branch/clone bar */
+ .code-bar {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-md);
+ flex-wrap: wrap;
+ }
+
+ .branch-picker {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ padding: var(--space-xs) var(--space-sm);
+ border-radius: var(--radius);
+ font-size: 0.85rem;
+ font-weight: 500;
+ }
+
+ .branch-picker .icon {
+ width: 14px;
+ height: 14px;
+ color: var(--text-secondary);
+ }
+
+ .code-bar .spacer {
+ flex: 1;
+ }
+
+ .clone-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ padding: var(--space-xs) var(--space-sm);
+ border-radius: var(--radius);
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ }
+
+ /* File table */
+ .file-table {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ background: var(--bg);
+ }
+
+ .file-table-header {
+ background: var(--bg-subtle);
+ padding: var(--space-sm) var(--space-md);
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ font-size: 0.85rem;
+ }
+
+ .file-table-header .commit-msg {
+ color: var(--text-secondary);
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .file-table-header .commit-hash {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ color: var(--text-link);
+ }
+
+ .file-table-header .commit-date {
+ color: var(--text-tertiary);
+ white-space: nowrap;
+ }
+
+ .file-table table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0;
+ }
+
+ .file-table td {
+ padding: var(--space-sm) var(--space-md);
+ border-top: 1px solid var(--border-subtle);
+ font-size: 0.85rem;
+ }
+
+ .file-table tr:first-child td {
+ border-top: none;
+ }
+
+ .file-table .col-name {
+ white-space: nowrap;
+ }
+
+ .file-table .col-msg {
+ color: var(--text-secondary);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 300px;
+ }
+
+ .file-table .col-date {
+ color: var(--text-tertiary);
+ white-space: nowrap;
+ text-align: right;
+ }
+
+ .file-table .entry {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ }
+
+ .file-table .icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ }
+
+ .file-table .icon-dir {
+ color: var(--accent);
+ }
+
+ .file-table .icon-file {
+ color: var(--text-tertiary);
+ }
+
+ /* Readme box */
+ .readme-box {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-top: var(--space-md);
+ background: var(--bg);
+ }
+
+ .readme-box-header {
+ background: var(--bg-subtle);
+ padding: var(--space-sm) var(--space-md);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.85rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ }
+
+ .readme-box-body {
+ padding: var(--space-lg) var(--space-xl);
+ }
+
+ /* Code/blob view */
+ pre {
+ background: var(--bg-subtle);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: var(--space-md);
+ overflow-x: auto;
+ margin: var(--space-md) 0;
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.6;
+ }
+
+ code {
+ font-family: var(--font-mono);
+ font-size: 13px;
+ }
+
+ :not(pre)>code {
+ background: var(--bg-inset);
+ padding: 0.15rem 0.4rem;
+ border-radius: 3px;
+ font-size: 0.85em;
+ }
+
+ /* Buttons */
+ .btn {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--accent);
+ color: #fff;
+ border: none;
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-family: var(--font-sans);
+ font-size: 0.85rem;
+ font-weight: 500;
+ }
+
+ .btn:hover {
+ background: var(--accent-hover);
+ text-decoration: none;
+ }
+
+ .btn-subtle {
+ background: var(--bg-subtle);
+ color: var(--text);
+ border: 1px solid var(--border);
+ }
+
+ .btn-subtle:hover {
+ background: var(--bg-inset);
+ }
+
+ /* Forms */
+ input,
+ textarea {
+ font-family: var(--font-sans);
+ font-size: 0.9rem;
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ width: 100%;
+ }
+
+ input:focus,
+ textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-subtle);
+ }
+
+ label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ display: block;
+ margin-bottom: var(--space-xs);
+ }
+
+ .form-group {
+ margin-bottom: var(--space-md);
+ }
+
+ /* Commit log */
+ .commit-list {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg);
+ }
+
+ .commit-list-item {
+ display: flex;
+ align-items: center;
+ gap: var(--space-md);
+ padding: var(--space-sm) var(--space-md);
+ border-top: 1px solid var(--border-subtle);
+ }
+
+ .commit-list-item:first-child {
+ border-top: none;
+ }
+
+ .commit-list-item .msg {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .commit-list-item .meta {
+ color: var(--text-tertiary);
+ font-size: 0.8rem;
+ white-space: nowrap;
+ }
+
+ .commit-list-item .hash {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ }
+
+ /* Empty state */
+ .empty-state {
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+ padding: var(--space-xl) var(--space-lg);
+ text-align: center;
+ color: var(--text-secondary);
+ margin: var(--space-lg) 0;
+ }
+
+ .empty-state pre {
+ text-align: left;
+ display: inline-block;
+ margin-top: var(--space-md);
+ }
+
+ /* Breadcrumb */
+ .breadcrumb {
+ margin-bottom: var(--space-md);
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ }
+
+ /* Diff */
+ .diff-add {
+ color: var(--diff-add);
+ }
+
+ .diff-del {
+ color: var(--diff-del);
+ }
+
+ /* Markdown rendering */
+ .rendered-md {
+ line-height: 1.7;
+ }
+
+ .rendered-md h1,
+ .rendered-md h2 {
+ margin: var(--space-lg) 0 var(--space-sm);
+ padding-bottom: var(--space-xs);
+ border-bottom: 1px solid var(--border);
+ }
+
+ .rendered-md h3 {
+ margin: var(--space-md) 0 var(--space-sm);
+ }
+
+ .rendered-md p {
+ margin-bottom: var(--space-md);
+ }
+
+ .rendered-md ul,
+ .rendered-md ol {
+ padding-left: var(--space-xl);
+ margin-bottom: var(--space-md);
+ }
+
+ .rendered-md pre {
+ margin: var(--space-md) 0;
+ }
+
+ .rendered-md img {
+ max-width: 100%;
+ }
+
+ /* Threads */
+ .threads-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--space-md);
+ }
+
+ .threads-state-toggle {
+ display: flex;
+ gap: var(--space-md);
+ }
+
+ .threads-state-toggle a {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 0.85rem;
+ }
+
+ .threads-state-toggle a.active {
+ color: var(--text-primary);
+ font-weight: 600;
+ }
+
+ .thread-list {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ overflow: hidden;
+ }
+
+ .thread-list-item {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ border-bottom: 1px solid var(--border);
+ }
+
+ .thread-list-item:last-child {
+ border-bottom: none;
+ }
+
+ .thread-list-item .icon {
+ width: 16px;
+ height: 16px;
+ flex-shrink: 0;
+ }
+
+ .thread-icon-open {
+ color: var(--diff-add);
+ margin-top: 2px;
+ }
+
+ .thread-icon-closed {
+ color: var(--accent);
+ margin-top: 2px;
+ }
+
+ .thread-list-content {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ }
+
+ .thread-title {
+ font-weight: 600;
+ color: var(--text-primary);
+ text-decoration: none;
+ display: block;
+ }
+
+ .thread-title:hover {
+ color: var(--accent);
+ }
+
+ .thread-meta {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ }
+
+ .thread-reply-count {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ color: var(--text-secondary);
+ font-size: 0.8rem;
+ flex-shrink: 0;
+ }
+
+ .refs-container {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-lg);
+ }
+
+ .refs-section h3 {
+ margin-bottom: var(--space-sm);
+ font-size: 0.9rem;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ }
+
+ .label-default {
+ font-size: 0.7rem;
+ padding: 2px 6px;
+ border-radius: 10px;
+ background: var(--accent);
+ color: white;
+ }
+
+ .thread-detail-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-xs);
+ }
+
+ .thread-detail-header h2 {
+ margin: 0;
+ font-size: 1.4rem;
+ }
+
+ .thread-state {
+ font-size: 0.75rem;
+ padding: 2px 8px;
+ border-radius: 999px;
+ font-weight: 600;
+ }
+
+ .thread-state-open {
+ background: var(--diff-add);
+ color: #fff;
+ }
+
+ .thread-state-closed {
+ background: var(--accent);
+ color: #fff;
+ }
+
+ .thread-meta-line {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: var(--space-lg);
+ }
+
+ .thread-comment {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin-bottom: var(--space-md);
+ }
+
+ .thread-comment-header {
+ display: flex;
+ justify-content: space-between;
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ font-size: 0.85rem;
+ border-radius: var(--radius) var(--radius) 0 0;
+ }
+
+ .thread-comment-body {
+ padding: var(--space-md);
+ }
+
+ .thread-reply-form {
+ margin-top: var(--space-lg);
+ }
+
+ .thread-reply-form textarea {
+ width: 100%;
+ font-family: inherit;
+ font-size: 0.9rem;
+ padding: var(--space-sm);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ resize: vertical;
+ }
+
+ .thread-actions {
+ display: flex;
+ gap: var(--space-sm);
+ margin-top: var(--space-sm);
+ }
+
+ .btn-secondary {
+ background: var(--bg-secondary);
+ color: var(--text-primary);
+ border: 1px solid var(--border);
+ padding: 6px 14px;
+ border-radius: var(--radius);
+ cursor: pointer;
+ font-size: 0.85rem;
+ }
+
+ .btn-secondary:hover {
+ background: var(--border);
+ }
+
+ .thread-new-form h2 {
+ margin-bottom: var(--space-md);
+ }
+
+ .patch-branches-hint {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: var(--space-md);
+ padding: var(--space-sm) var(--space-md);
+ background: var(--bg-secondary);
+ border-radius: var(--radius);
+ }
+
+ .patch-branches-hint code {
+ background: var(--bg-primary);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 0.8rem;
+ }
+
+ /* Responsive */
+ @media (max-width: 768px) {
+ .container {
+ padding: var(--space-md) var(--space-sm);
+ }
+
+ .code-bar {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .file-table .col-msg,
+ .file-table .col-date {
+ display: none;
+ }
+
+ .commit-list-item .meta {
+ display: none;
+ }
+ }
+ </style>
+</head>
+
+<body>
+ <header class="topbar">
+ <a class="logo" href="/">
+ <svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z" />
+ </svg>
+ fierj
+ </a>
+ <div class="spacer"></div>
+ <a class="nav-link" href="/new">+ New</a>
+ <button onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 12.5V2.5a5.5 5.5 0 0 1 0 11Z" />
+ </svg>
+ </button>
+ </header>
+ <main class="container">
+ {{end}}
+
+ {{define "foot"}}
+ </main>
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
+ <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
+ <link rel="stylesheet"
+ href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css">
+</body>
+
+</html>
+{{end}}
+
diff --git a/templates/partials.html b/templates/partials.html
new file mode 100644
index 0000000..ba54680
--- /dev/null
+++ b/templates/partials.html
@@ -0,0 +1,69 @@
+{{define "repo-header"}}
+<!-- Repo header -->
+<div class="repo-header">
+ <svg class="repo-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5Zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8ZM5 12.25a.25.25 0 0 1 .25-.25h3.5a.25.25 0 0 1 .25.25v3.25a.25.25 0 0 1-.4.2l-1.45-1.087a.249.249 0 0 0-.3 0L5.4 15.7a.25.25 0 0 1-.4-.2Z" />
+ </svg>
+ <h1><a href="/{{.Repo}}">{{.Repo}}</a></h1>
+ <span class="visibility">Public</span>
+</div>
+{{if .Description}}<p class="repo-desc">{{.Description}}</p>{{end}}
+{{end}}
+
+{{define "repo-tabs"}}
+<nav class="repo-tabs">
+ <a href="/{{.Repo}}" {{if eq .ActiveTab "code" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688Z" />
+ </svg>
+ Code
+ </a>
+ <a href="/{{.Repo}}/log/{{.Ref}}" {{if eq .ActiveTab "commits" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M1.643 3.143.427 1.927A.25.25 0 0 1 .604 1.5h3.71a.25.25 0 0 1 .177.427L3.275 3.143a3.5 3.5 0 1 0 5.6 1.476.75.75 0 1 1 1.388.568A5 5 0 1 1 1.643 3.143ZM8 4a.75.75 0 0 1 .75.75v2.44l1.53 1.53a.75.75 0 0 1-1.06 1.06l-1.72-1.72V4.75A.75.75 0 0 1 8 4Z" />
+ </svg>
+ Commits
+ {{if .Commits}}<span class="count">{{.Commits}}</span>{{end}}
+ </a>
+ {{if or .Branches .Tags}}
+ <a href="/{{.Repo}}/refs" {{if eq .ActiveTab "refs" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z" />
+ </svg>
+ Refs
+ <span class="count">{{len .Branches}}{{if .Tags}}+{{len .Tags}}{{end}}</span>
+ </a>
+ {{end}}
+ <a href="/{{.Repo}}/threads" {{if eq .ActiveTab "threads" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M0 2.75C0 1.784.784 1 1.75 1h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25Z" />
+ </svg>
+ Threads
+ </a>
+ <a href="/{{.Repo}}/patches" {{if eq .ActiveTab "patches" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path
+ d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354Z" />
+ </svg>
+ Patches
+ </a>
+ <a href="/{{.Repo}}/jobs" {{if eq .ActiveTab "jobs" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM4.5 7.5a.5.5 0 0 0 0 1h7a.5.5 0 0 0 0-1h-7Z"/>
+ </svg>
+ Jobs
+ </a>
+ <a href="/{{.Repo}}/settings" {{if eq .ActiveTab "settings" }} class="active" {{end}}>
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+ <path d="M8 0a2 2 0 0 1 2 2v.586l2.293-2.293a1 1 0 1 1 1.414 1.414L11.414 4H12a2 2 0 0 1 2 2v3.5a2.5 2.5 0 0 1-2.5 2.5H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 13.043V12H2a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h.586L.293 1.707a1 1 0 1 1 1.414-1.414L4 2.586V2a2 2 0 0 1 2-2Z"/>
+ </svg>
+ Settings
+ </a>
+</nav>
+{{end}}
+
diff --git a/templates/tree.html b/templates/tree.html
new file mode 100644
index 0000000..8737ed1
--- /dev/null
+++ b/templates/tree.html
@@ -0,0 +1,66 @@
+{{template "head" .}}
+{{define "title"}}{{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+{{if .Empty}}
+<div class="empty-state">
+ <p>Empty repository. Push your first commit:</p>
+ <pre><code>git remote add origin {{.CloneURL}}
+git push -u origin main</code></pre>
+</div>
+{{else}}
+
+<!-- Branch picker & clone -->
+<div class="code-bar">
+ <span class="branch-picker">
+ <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Z"/></svg>
+ {{.Ref}}
+ </span>
+ {{if .Breadcrumb}}
+ <span class="breadcrumb" style="font-size:0.85rem;color:var(--text-secondary);">
+ <a href="/{{.Repo}}">{{.Repo}}</a>
+ {{range .Breadcrumb}} / <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.URL}}">{{.Name}}</a>{{end}}
+ </span>
+ {{end}}
+ <span class="code-bar spacer"></span>
+ <span class="clone-btn">
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M4.72 3.22a.75.75 0 0 1 1.06 1.06L2.06 8l3.72 3.72a.75.75 0 1 1-1.06 1.06L.47 8.53a.75.75 0 0 1 0-1.06Zm6.56 0a.75.75 0 1 0-1.06 1.06L13.94 8l-3.72 3.72a.75.75 0 1 0 1.06 1.06l4.25-4.25a.75.75 0 0 0 0-1.06Z"/></svg>
+ {{.CloneURL}}
+ </span>
+ <a href="/{{.Repo}}/feed.xml" title="RSS feed" class="clone-btn" style="text-decoration:none;">
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2.002 2.725a.75.75 0 0 1 .797-.699C8.79 2.42 13.58 7.21 13.974 13.201a.75.75 0 0 1-1.497.098 10.502 10.502 0 0 0-9.776-9.776.747.747 0 0 1-.7-.798ZM2.84 7.05h-.002a7.002 7.002 0 0 1 6.113 6.111.75.75 0 0 1-1.5.008 5.501 5.501 0 0 0-4.8-4.8.75.75 0 0 1 .189-1.319ZM2 13a1 1 0 1 0 2 0 1 1 0 0 0-2 0Z"/></svg>
+ </a>
+</div>
+
+<!-- File table -->
+<div class="file-table">
+ <table>
+ {{range .Entries}}
+ <tr>
+ {{if eq .Type "tree"}}
+ <td class="col-name"><span class="entry"><svg class="icon icon-dir" viewBox="0 0 16 16" fill="currentColor"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1Z"/></svg><a href="/{{$.Repo}}/tree/{{$.Ref}}/{{pathJoin $.Path .Name}}">{{.Name}}</a></span></td>
+ {{else}}
+ <td class="col-name"><span class="entry"><svg class="icon icon-file" viewBox="0 0 16 16" fill="currentColor"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688Z"/></svg><a href="/{{$.Repo}}/blob/{{$.Ref}}/{{pathJoin $.Path .Name}}">{{.Name}}</a></span></td>
+ {{end}}
+ <td class="col-msg">{{if .LastCommit}}<a href="/{{$.Repo}}/commit/{{.LastCommit.Hash}}" style="color: var(--text-secondary);">{{.LastCommit.Message}}</a>{{end}}</td>
+ <td class="col-date">{{if .LastCommit}}{{.LastCommit.Date}}{{end}}</td>
+ </tr>
+ {{end}}
+ </table>
+</div>
+
+<!-- README -->
+{{if .Readme}}
+<div class="readme-box">
+ <div class="readme-box-header">
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/></svg>
+ README.md
+ </div>
+ <div class="readme-box-body markdown-body">{{.Readme}}</div>
+</div>
+{{end}}
+
+{{end}}
+{{template "foot" .}}
+