+ New

fierj

Public

Tiny 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" .}}
+