New Sign in

fierj

Public

Tiny personal git forge

150fff2cabd0c9547c07568c32584db89855daf4
diff --git a/activitypub.go b/activitypub.go
deleted file mode 100644
index 1e905b6..0000000
--- a/activitypub.go
+++ /dev/null
@@ -1,53 +0,0 @@
-package main
-
-import "log/slog"
-
-const apContext = "https://www.w3.org/ns/activitystreams"
-
-type APObject struct {
-	Context      string   `json:"@context"`
-	ID           string   `json:"id"`
-	Type         string   `json:"type"`
-	Published    string   `json:"published,omitempty"`
-	AttributedTo string   `json:"attributedTo,omitempty"`
-	Content      string   `json:"content,omitempty"`
-	URL          string   `json:"url,omitempty"`
-	To           []string `json:"to,omitempty"`
-	CC           []string `json:"cc,omitempty"`
-}
-
-func actorURL(host, repoName string) string {
-	return "https://" + host + "/" + repoName + "/ap/actor"
-}
-
-func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
-	if cfg.Host == "" {
-		return
-	}
-	actor := actorURL(cfg.Host, repoName)
-	threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
-	note := APObject{
-		Context:      apContext,
-		ID:           threadURL,
-		Type:         "Note",
-		Published:    t.Created,
-		AttributedTo: actor,
-		Content:      t.Title + "\n\n" + t.Body,
-		URL:          threadURL,
-		To:           []string{"https://www.w3.org/ns/activitystreams#Public"},
-		CC:           []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
-	}
-	activity := map[string]any{
-		"@context":  apContext,
-		"id":        threadURL + "/activity",
-		"type":      "Create",
-		"actor":     actor,
-		"published": t.Created,
-		"to":        []string{"https://www.w3.org/ns/activitystreams#Public"},
-		"cc":        []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
-		"object":    note,
-	}
-	// TODO: implement actual delivery to inboxes of followers.
-	slog.Info("ap: would deliver Create activity", "actor", actor, "thread", threadURL)
-	_ = activity
-}
diff --git a/ap.go b/ap.go
new file mode 100644
index 0000000..1e905b6
--- /dev/null
+++ b/ap.go
@@ -0,0 +1,53 @@
+package main
+
+import "log/slog"
+
+const apContext = "https://www.w3.org/ns/activitystreams"
+
+type APObject struct {
+	Context      string   `json:"@context"`
+	ID           string   `json:"id"`
+	Type         string   `json:"type"`
+	Published    string   `json:"published,omitempty"`
+	AttributedTo string   `json:"attributedTo,omitempty"`
+	Content      string   `json:"content,omitempty"`
+	URL          string   `json:"url,omitempty"`
+	To           []string `json:"to,omitempty"`
+	CC           []string `json:"cc,omitempty"`
+}
+
+func actorURL(host, repoName string) string {
+	return "https://" + host + "/" + repoName + "/ap/actor"
+}
+
+func deliverThreadCreate(cfg Config, repoName string, t *Thread) {
+	if cfg.Host == "" {
+		return
+	}
+	actor := actorURL(cfg.Host, repoName)
+	threadURL := "https://" + cfg.Host + "/" + repoName + "/threads/" + t.ID
+	note := APObject{
+		Context:      apContext,
+		ID:           threadURL,
+		Type:         "Note",
+		Published:    t.Created,
+		AttributedTo: actor,
+		Content:      t.Title + "\n\n" + t.Body,
+		URL:          threadURL,
+		To:           []string{"https://www.w3.org/ns/activitystreams#Public"},
+		CC:           []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+	}
+	activity := map[string]any{
+		"@context":  apContext,
+		"id":        threadURL + "/activity",
+		"type":      "Create",
+		"actor":     actor,
+		"published": t.Created,
+		"to":        []string{"https://www.w3.org/ns/activitystreams#Public"},
+		"cc":        []string{"https://" + cfg.Host + "/" + repoName + "/ap/followers"},
+		"object":    note,
+	}
+	// TODO: implement actual delivery to inboxes of followers.
+	slog.Info("ap: would deliver Create activity", "actor", actor, "thread", threadURL)
+	_ = activity
+}
diff --git a/auth.go b/auth.go
index f25446b..be0c89d 100644
--- a/auth.go
+++ b/auth.go
@@ -6,7 +6,6 @@ import (
 	"encoding/hex"
 	"encoding/json"
 	"fmt"
-	"html/template"
 	"net/http"
 	"os"
 	"strings"
@@ -132,39 +131,6 @@ func clearAuthCookie(w http.ResponseWriter) {
 	})
 }
 
-// ----- Handlers -----
-
-func LoginGet(tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		tmpl.ExecuteTemplate(w, "login.html", map[string]any{"User": User(r)})
-	}
-}
-
-func LoginPost(users UserStore, secret []byte, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		username := strings.TrimSpace(r.FormValue("username"))
-		password := r.FormValue("password")
-
-		if !users.Verify(username, password) {
-			tmpl.ExecuteTemplate(w, "login.html", map[string]any{
-				"Error": "Invalid username or password.",
-				"User":  User(r),
-			})
-			return
-		}
-
-		setAuthCookie(w, username, secret)
-		http.Redirect(w, r, "/", http.StatusSeeOther)
-	}
-}
-
-func Logout() http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		clearAuthCookie(w)
-		http.Redirect(w, r, "/", http.StatusSeeOther)
-	}
-}
-
 // ----- Middleware -----
 
 // AuthUser extracts the logged-in username from the cookie, or returns "".
@@ -183,37 +149,6 @@ func AuthUser(r *http.Request, secret []byte) string {
 	return username
 }
 
-// ----- Setup (first-run) -----
-
-func SetupGet(tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"User": User(r)})
-	}
-}
-
-func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		username := strings.TrimSpace(r.FormValue("username"))
-		password := r.FormValue("password")
-		confirm := r.FormValue("confirm")
-
-		if password != confirm {
-			tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"Error": "Passwords do not match.", "User": User(r)})
-			return
-		}
-		if err := users.Add(username, password); err != nil {
-			tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"Error": err.Error(), "User": User(r)})
-			return
-		}
-		if err := users.Save(usersPath); err != nil {
-			tmpl.ExecuteTemplate(w, "setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r)})
-			return
-		}
-		setAuthCookie(w, username, secret)
-		http.Redirect(w, r, "/", http.StatusSeeOther)
-	}
-}
-
 // SetupRedirect middleware: if no users exist, all requests go to /setup.
 func SetupRedirect(users UserStore) func(http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
@@ -226,59 +161,3 @@ func SetupRedirect(users UserStore) func(http.Handler) http.Handler {
 		})
 	}
 }
-
-// ----- User management -----
-
-func UsersGet(users UserStore, usersPath string, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if User(r) == "" {
-			http.Redirect(w, r, "/login", http.StatusSeeOther)
-			return
-		}
-		userList := make([]string, 0, len(users))
-		for u := range users {
-			userList = append(userList, u)
-		}
-		tmpl.ExecuteTemplate(w, "users.html", map[string]any{
-			"Users": userList,
-			"User":  User(r),
-		})
-	}
-}
-
-func UsersPost(users UserStore, usersPath string, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if User(r) == "" {
-			http.Redirect(w, r, "/login", http.StatusSeeOther)
-			return
-		}
-		action := r.FormValue("action")
-		switch action {
-		case "add":
-			username := strings.TrimSpace(r.FormValue("username"))
-			password := r.FormValue("password")
-			if err := users.Add(username, password); err != nil {
-				http.Error(w, err.Error(), http.StatusBadRequest)
-				return
-			}
-			users.Save(usersPath)
-		case "remove":
-			username := strings.TrimSpace(r.FormValue("username"))
-			if username == User(r) {
-				http.Error(w, "cannot remove yourself", http.StatusBadRequest)
-				return
-			}
-			users.Remove(username)
-			users.Save(usersPath)
-		case "change-password":
-			currentUser := User(r)
-			newPassword := r.FormValue("password")
-			if err := users.ChangePassword(currentUser, newPassword); err != nil {
-				http.Error(w, err.Error(), http.StatusBadRequest)
-				return
-			}
-			users.Save(usersPath)
-		}
-		http.Redirect(w, r, "/users", http.StatusSeeOther)
-	}
-}
diff --git a/auth_handlers.go b/auth_handlers.go
new file mode 100644
index 0000000..98c2add
--- /dev/null
+++ b/auth_handlers.go
@@ -0,0 +1,127 @@
+package main
+
+import (
+	"html/template"
+	"net/http"
+	"strings"
+)
+
+// ----- Login -----
+
+func LoginGet(tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		tmpl.ExecuteTemplate(w, "auth_login.html", map[string]any{"User": User(r)})
+	}
+}
+
+func LoginPost(users UserStore, secret []byte, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		username := strings.TrimSpace(r.FormValue("username"))
+		password := r.FormValue("password")
+
+		if !users.Verify(username, password) {
+			tmpl.ExecuteTemplate(w, "auth_login.html", map[string]any{
+				"Error": "Invalid username or password.",
+				"User":  User(r),
+			})
+			return
+		}
+
+		setAuthCookie(w, username, secret)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
+}
+
+func Logout() http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		clearAuthCookie(w)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
+}
+
+// ----- Setup (first-run) -----
+
+func SetupGet(tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"User": User(r)})
+	}
+}
+
+func SetupPost(users UserStore, usersPath string, secret []byte, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		username := strings.TrimSpace(r.FormValue("username"))
+		password := r.FormValue("password")
+		confirm := r.FormValue("confirm")
+
+		if password != confirm {
+			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Passwords do not match.", "User": User(r)})
+			return
+		}
+		if err := users.Add(username, password); err != nil {
+			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": err.Error(), "User": User(r)})
+			return
+		}
+		if err := users.Save(usersPath); err != nil {
+			tmpl.ExecuteTemplate(w, "auth_setup.html", map[string]any{"Error": "Failed to save: " + err.Error(), "User": User(r)})
+			return
+		}
+		setAuthCookie(w, username, secret)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
+}
+
+// ----- User management -----
+
+func UsersGet(users UserStore, usersPath string, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		userList := make([]string, 0, len(users))
+		for u := range users {
+			userList = append(userList, u)
+		}
+		tmpl.ExecuteTemplate(w, "auth_users.html", map[string]any{
+			"Users": userList,
+			"User":  User(r),
+		})
+	}
+}
+
+func UsersPost(users UserStore, usersPath string, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		action := r.FormValue("action")
+		switch action {
+		case "add":
+			username := strings.TrimSpace(r.FormValue("username"))
+			password := r.FormValue("password")
+			if err := users.Add(username, password); err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			users.Save(usersPath)
+		case "remove":
+			username := strings.TrimSpace(r.FormValue("username"))
+			if username == User(r) {
+				http.Error(w, "cannot remove yourself", http.StatusBadRequest)
+				return
+			}
+			users.Remove(username)
+			users.Save(usersPath)
+		case "change-password":
+			currentUser := User(r)
+			newPassword := r.FormValue("password")
+			if err := users.ChangePassword(currentUser, newPassword); err != nil {
+				http.Error(w, err.Error(), http.StatusBadRequest)
+				return
+			}
+			users.Save(usersPath)
+		}
+		http.Redirect(w, r, "/users", http.StatusSeeOther)
+	}
+}
diff --git a/handlers_patches.go b/handlers_patches.go
deleted file mode 100644
index 0c4cd4c..0000000
--- a/handlers_patches.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"html/template"
-	"io"
-	"net/http"
-	"strings"
-)
-
-func PatchesList(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		state := r.URL.Query().Get("state")
-		if state == "" {
-			state = "open"
-		}
-		ref := git.DefaultBranch()
-		rp := repoPath(cfg, git.Name)
-		patches, _ := listPatches(rp, state)
-		tmpl.ExecuteTemplate(w, "patches.html", withUser(r, map[string]any{
-			"Repo":         git.Repo(),
-			"IsPrivate":    git.IsPrivate(),
-			"Ref":          ref,
-			"Patches":      patches,
-			"State":        state,
-			"FreeBranches": nonDefaultBranches(rp),
-			"Description":  git.Description(),
-			"Branches":     git.Branches(),
-			"Tags":         git.Tags(),
-			"Commits":      git.CommitCount(ref),
-			"ActiveTab":    "patches",
-		}))
-	}
-}
-
-func PatchNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		ref := git.DefaultBranch()
-		rp := repoPath(cfg, git.Name)
-		tmpl.ExecuteTemplate(w, "patch_new.html", withUser(r, map[string]any{
-			"Repo":         git.Repo(),
-			"IsPrivate":    git.IsPrivate(),
-			"Ref":          ref,
-			"FreeBranches": nonDefaultBranches(rp),
-			"Description":  git.Description(),
-			"Branches":     git.Branches(),
-			"Tags":         git.Tags(),
-			"Commits":      git.CommitCount(ref),
-			"ActiveTab":    "patches",
-		}))
-	}
-}
-
-func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		title := strings.TrimSpace(r.FormValue("title"))
-		if title == "" {
-			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
-			return
-		}
-		body := strings.TrimSpace(r.FormValue("body"))
-		author, authorName := threadAuthor(r)
-		rp := repoPath(cfg, git.Name)
-
-		var p *Patch
-		var err error
-
-		branch := strings.TrimSpace(r.FormValue("branch"))
-		if branch != "" {
-			p, err = createPatchFromBranch(rp, branch, title, body, author, authorName)
-		} else {
-			// Try file upload
-			file, header, ferr := r.FormFile("patch_file")
-			if ferr != nil {
-				renderError(w, tmpl, http.StatusBadRequest, "Either a branch or a .patch file is required")
-				return
-			}
-			defer file.Close()
-			patchContent, rerr := io.ReadAll(file)
-			if rerr != nil || len(patchContent) == 0 {
-				renderError(w, tmpl, http.StatusBadRequest, "Failed to read patch file")
-				return
-			}
-			_ = header
-			p, err = createPatchFromFile(rp, title, body, author, authorName, patchContent)
-		}
-		if err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, p.ID), http.StatusSeeOther)
-	}
-}
-
-func PatchView(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		patchID := r.PathValue("patchId")
-		rp := repoPath(cfg, git.Name)
-		p, err := loadPatch(rp, patchID)
-		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
-			return
-		}
-		diff, _ := patchDiff(rp, p)
-		ref := git.DefaultBranch()
-		tmpl.ExecuteTemplate(w, "patch_view.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Patch":       p,
-			"Diff":        diff,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "patches",
-		}))
-	}
-}
-
-func PatchMergePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		patchID := r.PathValue("patchId")
-		rp := repoPath(cfg, git.Name)
-		p, err := loadPatch(rp, patchID)
-		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
-			return
-		}
-		if err := mergePatch(rp, p); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
-	}
-}
-
-func PatchClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		patchID := r.PathValue("patchId")
-		rp := repoPath(cfg, git.Name)
-		if err := closePatch(rp, patchID); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
-	}
-}
diff --git a/handlers_repo.go b/handlers_repo.go
deleted file mode 100644
index e3e5f51..0000000
--- a/handlers_repo.go
+++ /dev/null
@@ -1,447 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"html/template"
-	"log/slog"
-	"net"
-	"net/http"
-	"sort"
-	"strings"
-	"time"
-)
-
-type BreadcrumbItem struct {
-	Name string
-	URL  string
-}
-
-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 Breadcrumb(path string) []BreadcrumbItem {
-	if path == "" {
-		return nil
-	}
-	parts := strings.Split(path, "/")
-	items := make([]BreadcrumbItem, len(parts))
-	cumulative := ""
-	for i, p := range parts {
-		if i > 0 {
-			cumulative += "/"
-		}
-		cumulative += p
-		items[i] = BreadcrumbItem{Name: p, URL: cumulative}
-	}
-	return items
-}
-
-func withUser(r *http.Request, data map[string]any) map[string]any {
-	if data == nil {
-		data = map[string]any{}
-	}
-	data["User"] = User(r)
-	return data
-}
-
-// checkRepoAccess returns true if the current user can view this repo.
-// Private repos require login. Writes 404 or redirects to login if denied.
-func checkRepoAccess(w http.ResponseWriter, r *http.Request, git *Git) bool {
-	if git.IsPrivate() && User(r) == "" {
-		http.Redirect(w, r, "/login", http.StatusSeeOther)
-		return false
-	}
-	return true
-}
-
-func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		repos, err := ListRepos(cfg)
-		if err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, "failed to list repositories: "+err.Error())
-			return
-		}
-		// Filter private repos for anonymous users.
-		user := User(r)
-		visible := make([]*Git, 0, len(repos))
-		for _, repo := range repos {
-			if !repo.IsPrivate() || user != "" {
-				visible = append(visible, repo)
-			}
-		}
-		tmpl.ExecuteTemplate(w, "repos.html", map[string]any{
-			"Repos": visible,
-			"User":  user,
-		})
-	}
-}
-
-func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		type FileEntry struct {
-			TreeEntry
-			LastCommit *Commit
-		}
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		treePath := r.PathValue("path")
-		ref := r.PathValue("ref")
-		if ref == "" {
-			ref = git.DefaultBranch()
-		}
-		cloneSSH := ""
-		cloneHTTPS := ""
-		if cfg.Host != "" {
-			cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
-			// Include port in SSH URL if non-standard
-			if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
-				cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
-			} else {
-				cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
-			}
-		}
-		if git.IsEmpty() {
-			tmpl.ExecuteTemplate(w, "tree.html", withUser(r, map[string]any{
-				"Repo":        git.Repo(),
-				"IsPrivate":   git.IsPrivate(),
-				"Ref":         ref,
-				"Path":        "",
-				"Entries":     []FileEntry{},
-				"CloneSSH":    cloneSSH,
-				"CloneHTTPS":  cloneHTTPS,
-				"Description": git.Description(),
-				"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", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Path":        treePath,
-			"Breadcrumb":  Breadcrumb(treePath),
-			"Entries":     fileEntries,
-			"CloneSSH":    cloneSSH,
-			"CloneHTTPS":  cloneHTTPS,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"Readme":      readme,
-			"ActiveTab":   "code",
-		}))
-	}
-}
-
-func Blob(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		filePath := r.PathValue("path")
-		ref := r.PathValue("ref")
-		if ref == "" {
-			ref = git.DefaultBranch()
-		}
-
-		content, err := git.Blob(ref, filePath)
-		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, err.Error())
-			return
-		}
-		tmpl.ExecuteTemplate(w, "blob.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Path":        filePath,
-			"Breadcrumb":  Breadcrumb(filePath),
-			"Content":     content,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "code",
-		}))
-	}
-}
-
-func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		ref := r.PathValue("ref")
-		commits, err := git.Log(ref, 50)
-		if err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-		data := map[string]any{
-			"User":        User(r),
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"CommitList":  commits,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "commits",
-		}
-		tmpl.ExecuteTemplate(w, "log.html", data)
-	}
-}
-
-func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		ref := r.PathValue("ref")
-		commit, err := git.Diff(ref)
-		if err != nil || len(commit) == 0 {
-			renderError(w, tmpl, http.StatusNotFound, "commit not found")
-			return
-		}
-		if err := tmpl.ExecuteTemplate(w, "commit.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Diff":        commit,
-			"Hash":        ref,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "commits",
-		})); err != nil {
-			slog.Error("failed to execute template", "error", err)
-		}
-	}
-}
-
-func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if User(r) == "" {
-			http.Redirect(w, r, "/login", http.StatusSeeOther)
-			return
-		}
-		tmpl.ExecuteTemplate(w, "new_repo.html", map[string]any{"User": User(r)})
-	}
-}
-
-func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if User(r) == "" {
-			http.Redirect(w, r, "/login", http.StatusSeeOther)
-			return
-		}
-		name := strings.TrimSpace(r.FormValue("name"))
-		desc := strings.TrimSpace(r.FormValue("description"))
-		importURL := strings.TrimSpace(r.FormValue("import_url"))
-
-		if importURL != "" {
-			importedName, err := ImportRepo(cfg, importURL, desc)
-			if err != nil {
-				renderError(w, tmpl, http.StatusBadRequest, "failed to import: "+err.Error())
-				return
-			}
-			http.Redirect(w, r, "/"+importedName, http.StatusSeeOther)
-			return
-		}
-
-		if name == "" {
-			renderError(w, tmpl, http.StatusBadRequest, "repository name is required")
-			return
-		}
-		if err := InitRepo(cfg, name, desc); err != nil {
-			renderError(w, tmpl, http.StatusBadRequest, "failed to create repository: "+err.Error())
-			return
-		}
-		http.Redirect(w, r, "/"+name, http.StatusSeeOther)
-	}
-}
-
-func Refs(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		ref := git.DefaultBranch()
-		tmpl.ExecuteTemplate(w, "refs.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Description": git.Description(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "refs",
-		}))
-	}
-}
-
-func SettingsGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if User(r) == "" {
-			http.Redirect(w, r, "/login", http.StatusSeeOther)
-			return
-		}
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		meta := git.LoadMeta()
-		ref := git.DefaultBranch()
-		tmpl.ExecuteTemplate(w, "settings.html", map[string]any{
-			"Repo":              git.Repo(),
-			"Ref":               ref,
-			"IsPrivate":         meta.IsPrivate,
-			"Description":       meta.Description,
-			"Branches":          git.Branches(),
-			"Tags":              git.Tags(),
-			"Commits":           git.CommitCount(ref),
-			"AuthorizedKeys":    strings.Join(meta.AuthorizedKeys, "\n"),
-			"ProtectedBranches": strings.Join(meta.ProtectedBranches, "\n"),
-			"ActiveTab":         "settings",
-			"User":              User(r),
-		})
-	}
-}
-
-func SettingsPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		if User(r) == "" {
-			http.Redirect(w, r, "/login", http.StatusSeeOther)
-			return
-		}
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		meta := git.LoadMeta()
-		meta.Description = strings.TrimSpace(r.FormValue("description"))
-		meta.IsPrivate = r.FormValue("is_private") == "true"
-
-		// Parse authorized keys: one key per line, skip empty lines
-		rawKeys := strings.TrimSpace(r.FormValue("authorized_keys"))
-		if rawKeys != "" {
-			meta.AuthorizedKeys = nil
-			for _, k := range strings.Split(rawKeys, "\n") {
-				k = strings.TrimSpace(k)
-				if k != "" {
-					meta.AuthorizedKeys = append(meta.AuthorizedKeys, k)
-				}
-			}
-		} else {
-			meta.AuthorizedKeys = nil
-		}
-
-		// Parse protected branches
-		rawBranches := strings.TrimSpace(r.FormValue("protected_branches"))
-		if rawBranches != "" {
-			meta.ProtectedBranches = nil
-			for _, b := range strings.Split(rawBranches, "\n") {
-				b = strings.TrimSpace(b)
-				if b != "" {
-					meta.ProtectedBranches = append(meta.ProtectedBranches, b)
-				}
-			}
-		} else {
-			meta.ProtectedBranches = nil
-		}
-
-		if err := git.SaveMeta(meta); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, "failed to save settings: "+err.Error())
-			return
-		}
-		http.Redirect(w, r, "/"+git.Name+"/settings", http.StatusSeeOther)
-	}
-}
diff --git a/handlers_threads.go b/handlers_threads.go
deleted file mode 100644
index b9ea03f..0000000
--- a/handlers_threads.go
+++ /dev/null
@@ -1,186 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"html/template"
-	"net/http"
-	"path/filepath"
-	"strings"
-)
-
-func repoPath(cfg Config, repoName string) string {
-	return filepath.Join(cfg.Dir, repoName+".git")
-}
-
-func ThreadsList(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		state := r.URL.Query().Get("state")
-		if state == "" {
-			state = "open"
-		}
-		ref := git.DefaultBranch()
-		rp := repoPath(cfg, git.Name)
-		threads, openCount, closedCount := listThreadsFiltered(rp, state)
-		tmpl.ExecuteTemplate(w, "threads.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Threads":     threads,
-			"State":       state,
-			"OpenCount":   openCount,
-			"ClosedCount": closedCount,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "threads",
-		}))
-	}
-}
-
-func ThreadNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		ref := git.DefaultBranch()
-		tmpl.ExecuteTemplate(w, "thread_new.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "threads",
-		}))
-	}
-}
-
-func ThreadNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		title := strings.TrimSpace(r.FormValue("title"))
-		body := strings.TrimSpace(r.FormValue("body"))
-		if title == "" {
-			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
-			return
-		}
-		author, authorName := threadAuthor(r)
-		rp := repoPath(cfg, git.Name)
-		t, err := createThread(rp, title, body, author, authorName)
-		if err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-		// Deliver to ActivityPub followers (non-blocking).
-		go deliverThreadCreate(cfg, git.Name, t)
-		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, t.ID), http.StatusSeeOther)
-	}
-}
-
-func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		threadID := r.PathValue("threadId")
-		rp := repoPath(cfg, git.Name)
-		t, err := loadThread(rp, threadID)
-		if err != nil {
-			renderError(w, tmpl, http.StatusNotFound, "Thread not found")
-			return
-		}
-		ref := git.DefaultBranch()
-		tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
-			"Repo":        git.Repo(),
-			"IsPrivate":   git.IsPrivate(),
-			"Ref":         ref,
-			"Thread":      t,
-			"Description": git.Description(),
-			"Branches":    git.Branches(),
-			"Tags":        git.Tags(),
-			"Commits":     git.CommitCount(ref),
-			"ActiveTab":   "threads",
-		}))
-	}
-}
-
-func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		threadID := r.PathValue("threadId")
-		body := strings.TrimSpace(r.FormValue("body"))
-		if body == "" {
-			http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
-			return
-		}
-		author, authorName := threadAuthor(r)
-		rp := repoPath(cfg, git.Name)
-		if err := addReply(rp, threadID, body, author, authorName); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
-	}
-}
-
-func threadAuthor(r *http.Request) (author, displayName string) {
-	author = User(r)
-	if author == "" {
-		author = "anonymous"
-	}
-	return author, author
-}
-
-func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *http.Request, tmpl *template.Template, w http.ResponseWriter, action func(string, string) error) {
-	body := strings.TrimSpace(r.FormValue("body"))
-	rp := repoPath(cfg, repoName)
-
-	// If the user wrote a comment, save it before changing state.
-	if body != "" {
-		author, authorName := threadAuthor(r)
-		if err := addReply(rp, threadID, body, author, authorName); err != nil {
-			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-			return
-		}
-	}
-
-	if err := action(rp, threadID); err != nil {
-		renderError(w, tmpl, http.StatusInternalServerError, err.Error())
-		return
-	}
-	http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", repoName, threadID), http.StatusSeeOther)
-}
-
-func ThreadClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, closeThread)
-	}
-}
-
-func ThreadReopenPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
-	return func(w http.ResponseWriter, r *http.Request) {
-		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
-		if !checkRepoAccess(w, r, git) {
-			return
-		}
-		closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, reopenThread)
-	}
-}
diff --git a/patch.go b/patch.go
new file mode 100644
index 0000000..4681ec4
--- /dev/null
+++ b/patch.go
@@ -0,0 +1,252 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"strings"
+	"sync"
+	"time"
+)
+
+// patchLocks protects concurrent read-modify-write per patch file.
+var patchLocks sync.Map // map[string]*sync.Mutex
+
+func lockPatch(patchPath string) func() {
+	mu := &sync.Mutex{}
+	actual, _ := patchLocks.LoadOrStore(patchPath, mu)
+	mu = actual.(*sync.Mutex)
+	mu.Lock()
+	return func() { mu.Unlock() }
+}
+
+// Patch represents an uploaded patch or a branch-based merge proposal.
+type Patch struct {
+	ID         string `json:"id"`
+	State      string `json:"state"` // "open", "merged", "closed"
+	Title      string `json:"title"`
+	Body       string `json:"body"`
+	Author     string `json:"author"`
+	AuthorName string `json:"author_name"`
+	Created    string `json:"created"`
+	Updated    string `json:"updated"`
+	// Branch-based
+	Branch string `json:"branch,omitempty"`
+	// File-based
+	PatchFile string `json:"patch_file,omitempty"`
+}
+
+func patchesDir(repoPath string) string {
+	return filepath.Join(repoPath, ".forge", "patches")
+}
+
+func patchDataDir(repoPath string) string {
+	return filepath.Join(repoPath, ".forge", "patch-files")
+}
+
+// createPatchFromBranch creates a patch entry for an internal branch.
+func createPatchFromBranch(repoPath, branch, title, body, author, authorName string) (*Patch, error) {
+	dir := patchesDir(repoPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, err
+	}
+
+	now := time.Now().UTC().Format(time.RFC3339)
+	p := &Patch{
+		ID:         newULID(),
+		State:      "open",
+		Title:      title,
+		Body:       body,
+		Author:     author,
+		AuthorName: authorName,
+		Branch:     branch,
+		Created:    now,
+		Updated:    now,
+	}
+	return p, writePatch(repoPath, p)
+}
+
+// createPatchFromFile creates a patch entry from an uploaded .patch file.
+func createPatchFromFile(repoPath, title, body, author, authorName string, patchContent []byte) (*Patch, error) {
+	dir := patchesDir(repoPath)
+	fileDir := patchDataDir(repoPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, err
+	}
+	if err := os.MkdirAll(fileDir, 0755); err != nil {
+		return nil, err
+	}
+
+	id := newULID()
+	now := time.Now().UTC().Format(time.RFC3339)
+
+	// Save the patch file
+	patchFileName := id + ".patch"
+	if err := os.WriteFile(filepath.Join(fileDir, patchFileName), patchContent, 0644); err != nil {
+		return nil, err
+	}
+
+	p := &Patch{
+		ID:         id,
+		State:      "open",
+		Title:      title,
+		Body:       body,
+		Author:     author,
+		AuthorName: authorName,
+		PatchFile:  patchFileName,
+		Created:    now,
+		Updated:    now,
+	}
+	return p, writePatch(repoPath, p)
+}
+
+// listPatches returns all patches filtered by state.
+func listPatches(repoPath, state string) ([]Patch, error) {
+	dir := patchesDir(repoPath)
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		if os.IsNotExist(err) {
+			return nil, nil
+		}
+		return nil, err
+	}
+
+	var patches []Patch
+	for _, e := range entries {
+		if !strings.HasSuffix(e.Name(), ".json") {
+			continue
+		}
+		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
+		if err != nil {
+			continue
+		}
+		var p Patch
+		if err := json.Unmarshal(data, &p); err != nil {
+			continue
+		}
+		if state == "" || p.State == state {
+			patches = append(patches, p)
+		}
+	}
+	return patches, nil
+}
+
+// loadPatch reads a single patch.
+func loadPatch(repoPath, patchID string) (*Patch, error) {
+	data, err := os.ReadFile(filepath.Join(patchesDir(repoPath), patchID+".json"))
+	if err != nil {
+		return nil, err
+	}
+	var p Patch
+	if err := json.Unmarshal(data, &p); err != nil {
+		return nil, err
+	}
+	return &p, nil
+}
+
+// patchDiff returns the diff for a patch (either branch-based or file-based).
+func patchDiff(repoPath string, p *Patch) (string, error) {
+	if p.Branch != "" {
+		// Diff between default branch and patch branch
+		defBranch := defaultBranch(repoPath)
+		cmd := exec.Command("git", "diff", defBranch+"..."+p.Branch)
+		cmd.Dir = repoPath
+		out, err := cmd.Output()
+		if err != nil {
+			return "", err
+		}
+		return string(out), nil
+	}
+	if p.PatchFile != "" {
+		data, err := os.ReadFile(filepath.Join(patchDataDir(repoPath), p.PatchFile))
+		if err != nil {
+			return "", err
+		}
+		return string(data), nil
+	}
+	return "", fmt.Errorf("no diff source")
+}
+
+// mergePatch merges a branch-based patch or applies a file-based patch.
+func mergePatch(repoPath string, p *Patch) error {
+	patchPath := filepath.Join(patchesDir(repoPath), p.ID+".json")
+	unlock := lockPatch(patchPath)
+	defer unlock()
+
+	// Re-read patch under lock to get fresh state
+	current, err := loadPatch(repoPath, p.ID)
+	if err != nil {
+		return err
+	}
+	if current.State != "open" {
+		return fmt.Errorf("patch is not open")
+	}
+	if current.Branch != "" {
+		cmd := exec.Command("git", "merge", "--ff-only", current.Branch)
+		cmd.Dir = repoPath
+		if err := cmd.Run(); err != nil {
+			// Try non-ff merge
+			cmd = exec.Command("git", "merge", "--no-ff", "-m", "Merge "+current.Branch, current.Branch)
+			cmd.Dir = repoPath
+			if err := cmd.Run(); err != nil {
+				return fmt.Errorf("merge failed: %w", err)
+			}
+		}
+	} else if current.PatchFile != "" {
+		patchFilePath := filepath.Join(patchDataDir(repoPath), current.PatchFile)
+		cmd := exec.Command("git", "am", patchFilePath)
+		cmd.Dir = repoPath
+		if err := cmd.Run(); err != nil {
+			return fmt.Errorf("git am failed: %w", err)
+		}
+	}
+
+	current.State = "merged"
+	current.Updated = time.Now().UTC().Format(time.RFC3339)
+	return writePatch(repoPath, current)
+}
+
+// closePatch closes a patch without merging.
+func closePatch(repoPath, patchID string) error {
+	patchPath := filepath.Join(patchesDir(repoPath), patchID+".json")
+	unlock := lockPatch(patchPath)
+	defer unlock()
+
+	p, err := loadPatch(repoPath, patchID)
+	if err != nil {
+		return err
+	}
+	p.State = "closed"
+	p.Updated = time.Now().UTC().Format(time.RFC3339)
+	return writePatch(repoPath, p)
+}
+
+func writePatch(repoPath string, p *Patch) error {
+	dir := patchesDir(repoPath)
+	os.MkdirAll(dir, 0755)
+	data, err := json.MarshalIndent(p, "", "  ")
+	if err != nil {
+		return err
+	}
+	target := filepath.Join(dir, p.ID+".json")
+	tmp := target + ".tmp"
+	if err := os.WriteFile(tmp, data, 0644); err != nil {
+		return err
+	}
+	return os.Rename(tmp, target)
+}
+
+// nonDefaultBranches returns branches that aren't the default (potential patches).
+func nonDefaultBranches(repoPath string) []string {
+	def := defaultBranch(repoPath)
+	all := listBranches(repoPath)
+	var result []string
+	for _, b := range all {
+		if b != def && !strings.HasPrefix(b, "wip/") {
+			result = append(result, b)
+		}
+	}
+	return result
+}
diff --git a/patch_handlers.go b/patch_handlers.go
new file mode 100644
index 0000000..a8eb23d
--- /dev/null
+++ b/patch_handlers.go
@@ -0,0 +1,172 @@
+package main
+
+import (
+	"fmt"
+	"html/template"
+	"io"
+	"net/http"
+	"strings"
+)
+
+func PatchesList(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		state := r.URL.Query().Get("state")
+		if state == "" {
+			state = "open"
+		}
+		ref := git.DefaultBranch()
+		rp := repoPath(cfg, git.Name)
+		patches, _ := listPatches(rp, state)
+		tmpl.ExecuteTemplate(w, "patch_list.html", withUser(r, map[string]any{
+			"Repo":         git.Repo(),
+			"IsPrivate":    git.IsPrivate(),
+			"Ref":          ref,
+			"Patches":      patches,
+			"State":        state,
+			"FreeBranches": nonDefaultBranches(rp),
+			"Description":  git.Description(),
+			"Branches":     git.Branches(),
+			"Tags":         git.Tags(),
+			"Commits":      git.CommitCount(ref),
+			"ActiveTab":    "patches",
+		}))
+	}
+}
+
+func PatchNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		ref := git.DefaultBranch()
+		rp := repoPath(cfg, git.Name)
+		tmpl.ExecuteTemplate(w, "patch_new.html", withUser(r, map[string]any{
+			"Repo":         git.Repo(),
+			"IsPrivate":    git.IsPrivate(),
+			"Ref":          ref,
+			"FreeBranches": nonDefaultBranches(rp),
+			"Description":  git.Description(),
+			"Branches":     git.Branches(),
+			"Tags":         git.Tags(),
+			"Commits":      git.CommitCount(ref),
+			"ActiveTab":    "patches",
+		}))
+	}
+}
+
+func PatchNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		title := strings.TrimSpace(r.FormValue("title"))
+		if title == "" {
+			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
+			return
+		}
+		body := strings.TrimSpace(r.FormValue("body"))
+		author, authorName := threadAuthor(r)
+		rp := repoPath(cfg, git.Name)
+
+		var p *Patch
+		var err error
+
+		branch := strings.TrimSpace(r.FormValue("branch"))
+		if branch != "" {
+			p, err = createPatchFromBranch(rp, branch, title, body, author, authorName)
+		} else {
+			// Try file upload
+			file, header, ferr := r.FormFile("patch_file")
+			if ferr != nil {
+				renderError(w, tmpl, http.StatusBadRequest, "Either a branch or a .patch file is required")
+				return
+			}
+			defer file.Close()
+			patchContent, rerr := io.ReadAll(file)
+			if rerr != nil || len(patchContent) == 0 {
+				renderError(w, tmpl, http.StatusBadRequest, "Failed to read patch file")
+				return
+			}
+			_ = header
+			p, err = createPatchFromFile(rp, title, body, author, authorName, patchContent)
+		}
+		if err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, p.ID), http.StatusSeeOther)
+	}
+}
+
+func PatchView(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		patchID := r.PathValue("patchId")
+		rp := repoPath(cfg, git.Name)
+		p, err := loadPatch(rp, patchID)
+		if err != nil {
+			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
+			return
+		}
+		diff, _ := patchDiff(rp, p)
+		ref := git.DefaultBranch()
+		tmpl.ExecuteTemplate(w, "patch_view.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Patch":       p,
+			"Diff":        diff,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "patches",
+		}))
+	}
+}
+
+func PatchMergePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		patchID := r.PathValue("patchId")
+		rp := repoPath(cfg, git.Name)
+		p, err := loadPatch(rp, patchID)
+		if err != nil {
+			renderError(w, tmpl, http.StatusNotFound, "Patch not found")
+			return
+		}
+		if err := mergePatch(rp, p); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
+	}
+}
+
+func PatchClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		patchID := r.PathValue("patchId")
+		rp := repoPath(cfg, git.Name)
+		if err := closePatch(rp, patchID); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/patches/%s", git.Name, patchID), http.StatusSeeOther)
+	}
+}
diff --git a/patches.go b/patches.go
deleted file mode 100644
index 4681ec4..0000000
--- a/patches.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package main
-
-import (
-	"encoding/json"
-	"fmt"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"strings"
-	"sync"
-	"time"
-)
-
-// patchLocks protects concurrent read-modify-write per patch file.
-var patchLocks sync.Map // map[string]*sync.Mutex
-
-func lockPatch(patchPath string) func() {
-	mu := &sync.Mutex{}
-	actual, _ := patchLocks.LoadOrStore(patchPath, mu)
-	mu = actual.(*sync.Mutex)
-	mu.Lock()
-	return func() { mu.Unlock() }
-}
-
-// Patch represents an uploaded patch or a branch-based merge proposal.
-type Patch struct {
-	ID         string `json:"id"`
-	State      string `json:"state"` // "open", "merged", "closed"
-	Title      string `json:"title"`
-	Body       string `json:"body"`
-	Author     string `json:"author"`
-	AuthorName string `json:"author_name"`
-	Created    string `json:"created"`
-	Updated    string `json:"updated"`
-	// Branch-based
-	Branch string `json:"branch,omitempty"`
-	// File-based
-	PatchFile string `json:"patch_file,omitempty"`
-}
-
-func patchesDir(repoPath string) string {
-	return filepath.Join(repoPath, ".forge", "patches")
-}
-
-func patchDataDir(repoPath string) string {
-	return filepath.Join(repoPath, ".forge", "patch-files")
-}
-
-// createPatchFromBranch creates a patch entry for an internal branch.
-func createPatchFromBranch(repoPath, branch, title, body, author, authorName string) (*Patch, error) {
-	dir := patchesDir(repoPath)
-	if err := os.MkdirAll(dir, 0755); err != nil {
-		return nil, err
-	}
-
-	now := time.Now().UTC().Format(time.RFC3339)
-	p := &Patch{
-		ID:         newULID(),
-		State:      "open",
-		Title:      title,
-		Body:       body,
-		Author:     author,
-		AuthorName: authorName,
-		Branch:     branch,
-		Created:    now,
-		Updated:    now,
-	}
-	return p, writePatch(repoPath, p)
-}
-
-// createPatchFromFile creates a patch entry from an uploaded .patch file.
-func createPatchFromFile(repoPath, title, body, author, authorName string, patchContent []byte) (*Patch, error) {
-	dir := patchesDir(repoPath)
-	fileDir := patchDataDir(repoPath)
-	if err := os.MkdirAll(dir, 0755); err != nil {
-		return nil, err
-	}
-	if err := os.MkdirAll(fileDir, 0755); err != nil {
-		return nil, err
-	}
-
-	id := newULID()
-	now := time.Now().UTC().Format(time.RFC3339)
-
-	// Save the patch file
-	patchFileName := id + ".patch"
-	if err := os.WriteFile(filepath.Join(fileDir, patchFileName), patchContent, 0644); err != nil {
-		return nil, err
-	}
-
-	p := &Patch{
-		ID:         id,
-		State:      "open",
-		Title:      title,
-		Body:       body,
-		Author:     author,
-		AuthorName: authorName,
-		PatchFile:  patchFileName,
-		Created:    now,
-		Updated:    now,
-	}
-	return p, writePatch(repoPath, p)
-}
-
-// listPatches returns all patches filtered by state.
-func listPatches(repoPath, state string) ([]Patch, error) {
-	dir := patchesDir(repoPath)
-	entries, err := os.ReadDir(dir)
-	if err != nil {
-		if os.IsNotExist(err) {
-			return nil, nil
-		}
-		return nil, err
-	}
-
-	var patches []Patch
-	for _, e := range entries {
-		if !strings.HasSuffix(e.Name(), ".json") {
-			continue
-		}
-		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
-		if err != nil {
-			continue
-		}
-		var p Patch
-		if err := json.Unmarshal(data, &p); err != nil {
-			continue
-		}
-		if state == "" || p.State == state {
-			patches = append(patches, p)
-		}
-	}
-	return patches, nil
-}
-
-// loadPatch reads a single patch.
-func loadPatch(repoPath, patchID string) (*Patch, error) {
-	data, err := os.ReadFile(filepath.Join(patchesDir(repoPath), patchID+".json"))
-	if err != nil {
-		return nil, err
-	}
-	var p Patch
-	if err := json.Unmarshal(data, &p); err != nil {
-		return nil, err
-	}
-	return &p, nil
-}
-
-// patchDiff returns the diff for a patch (either branch-based or file-based).
-func patchDiff(repoPath string, p *Patch) (string, error) {
-	if p.Branch != "" {
-		// Diff between default branch and patch branch
-		defBranch := defaultBranch(repoPath)
-		cmd := exec.Command("git", "diff", defBranch+"..."+p.Branch)
-		cmd.Dir = repoPath
-		out, err := cmd.Output()
-		if err != nil {
-			return "", err
-		}
-		return string(out), nil
-	}
-	if p.PatchFile != "" {
-		data, err := os.ReadFile(filepath.Join(patchDataDir(repoPath), p.PatchFile))
-		if err != nil {
-			return "", err
-		}
-		return string(data), nil
-	}
-	return "", fmt.Errorf("no diff source")
-}
-
-// mergePatch merges a branch-based patch or applies a file-based patch.
-func mergePatch(repoPath string, p *Patch) error {
-	patchPath := filepath.Join(patchesDir(repoPath), p.ID+".json")
-	unlock := lockPatch(patchPath)
-	defer unlock()
-
-	// Re-read patch under lock to get fresh state
-	current, err := loadPatch(repoPath, p.ID)
-	if err != nil {
-		return err
-	}
-	if current.State != "open" {
-		return fmt.Errorf("patch is not open")
-	}
-	if current.Branch != "" {
-		cmd := exec.Command("git", "merge", "--ff-only", current.Branch)
-		cmd.Dir = repoPath
-		if err := cmd.Run(); err != nil {
-			// Try non-ff merge
-			cmd = exec.Command("git", "merge", "--no-ff", "-m", "Merge "+current.Branch, current.Branch)
-			cmd.Dir = repoPath
-			if err := cmd.Run(); err != nil {
-				return fmt.Errorf("merge failed: %w", err)
-			}
-		}
-	} else if current.PatchFile != "" {
-		patchFilePath := filepath.Join(patchDataDir(repoPath), current.PatchFile)
-		cmd := exec.Command("git", "am", patchFilePath)
-		cmd.Dir = repoPath
-		if err := cmd.Run(); err != nil {
-			return fmt.Errorf("git am failed: %w", err)
-		}
-	}
-
-	current.State = "merged"
-	current.Updated = time.Now().UTC().Format(time.RFC3339)
-	return writePatch(repoPath, current)
-}
-
-// closePatch closes a patch without merging.
-func closePatch(repoPath, patchID string) error {
-	patchPath := filepath.Join(patchesDir(repoPath), patchID+".json")
-	unlock := lockPatch(patchPath)
-	defer unlock()
-
-	p, err := loadPatch(repoPath, patchID)
-	if err != nil {
-		return err
-	}
-	p.State = "closed"
-	p.Updated = time.Now().UTC().Format(time.RFC3339)
-	return writePatch(repoPath, p)
-}
-
-func writePatch(repoPath string, p *Patch) error {
-	dir := patchesDir(repoPath)
-	os.MkdirAll(dir, 0755)
-	data, err := json.MarshalIndent(p, "", "  ")
-	if err != nil {
-		return err
-	}
-	target := filepath.Join(dir, p.ID+".json")
-	tmp := target + ".tmp"
-	if err := os.WriteFile(tmp, data, 0644); err != nil {
-		return err
-	}
-	return os.Rename(tmp, target)
-}
-
-// nonDefaultBranches returns branches that aren't the default (potential patches).
-func nonDefaultBranches(repoPath string) []string {
-	def := defaultBranch(repoPath)
-	all := listBranches(repoPath)
-	var result []string
-	for _, b := range all {
-		if b != def && !strings.HasPrefix(b, "wip/") {
-			result = append(result, b)
-		}
-	}
-	return result
-}
diff --git a/repo_handlers.go b/repo_handlers.go
new file mode 100644
index 0000000..563c572
--- /dev/null
+++ b/repo_handlers.go
@@ -0,0 +1,447 @@
+package main
+
+import (
+	"fmt"
+	"html/template"
+	"log/slog"
+	"net"
+	"net/http"
+	"sort"
+	"strings"
+	"time"
+)
+
+type BreadcrumbItem struct {
+	Name string
+	URL  string
+}
+
+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 Breadcrumb(path string) []BreadcrumbItem {
+	if path == "" {
+		return nil
+	}
+	parts := strings.Split(path, "/")
+	items := make([]BreadcrumbItem, len(parts))
+	cumulative := ""
+	for i, p := range parts {
+		if i > 0 {
+			cumulative += "/"
+		}
+		cumulative += p
+		items[i] = BreadcrumbItem{Name: p, URL: cumulative}
+	}
+	return items
+}
+
+func withUser(r *http.Request, data map[string]any) map[string]any {
+	if data == nil {
+		data = map[string]any{}
+	}
+	data["User"] = User(r)
+	return data
+}
+
+// checkRepoAccess returns true if the current user can view this repo.
+// Private repos require login. Writes 404 or redirects to login if denied.
+func checkRepoAccess(w http.ResponseWriter, r *http.Request, git *Git) bool {
+	if git.IsPrivate() && User(r) == "" {
+		http.Redirect(w, r, "/login", http.StatusSeeOther)
+		return false
+	}
+	return true
+}
+
+func Repos(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		repos, err := ListRepos(cfg)
+		if err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, "failed to list repositories: "+err.Error())
+			return
+		}
+		// Filter private repos for anonymous users.
+		user := User(r)
+		visible := make([]*Git, 0, len(repos))
+		for _, repo := range repos {
+			if !repo.IsPrivate() || user != "" {
+				visible = append(visible, repo)
+			}
+		}
+		tmpl.ExecuteTemplate(w, "repo_list.html", map[string]any{
+			"Repos": visible,
+			"User":  user,
+		})
+	}
+}
+
+func Tree(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		type FileEntry struct {
+			TreeEntry
+			LastCommit *Commit
+		}
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		treePath := r.PathValue("path")
+		ref := r.PathValue("ref")
+		if ref == "" {
+			ref = git.DefaultBranch()
+		}
+		cloneSSH := ""
+		cloneHTTPS := ""
+		if cfg.Host != "" {
+			cloneHTTPS = fmt.Sprintf("https://%s/%s.git", cfg.Host, git.Name)
+			// Include port in SSH URL if non-standard
+			if _, port, _ := net.SplitHostPort(cfg.SSHAddr); port != "" && port != "22" {
+				cloneSSH = fmt.Sprintf("ssh://git@%s:%s/%s.git", cfg.Host, port, git.Name)
+			} else {
+				cloneSSH = fmt.Sprintf("git@%s:%s.git", cfg.Host, git.Name)
+			}
+		}
+		if git.IsEmpty() {
+			tmpl.ExecuteTemplate(w, "repo_tree.html", withUser(r, map[string]any{
+				"Repo":        git.Repo(),
+				"IsPrivate":   git.IsPrivate(),
+				"Ref":         ref,
+				"Path":        "",
+				"Entries":     []FileEntry{},
+				"CloneSSH":    cloneSSH,
+				"CloneHTTPS":  cloneHTTPS,
+				"Description": git.Description(),
+				"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, "repo_tree.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Path":        treePath,
+			"Breadcrumb":  Breadcrumb(treePath),
+			"Entries":     fileEntries,
+			"CloneSSH":    cloneSSH,
+			"CloneHTTPS":  cloneHTTPS,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"Readme":      readme,
+			"ActiveTab":   "code",
+		}))
+	}
+}
+
+func Blob(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		filePath := r.PathValue("path")
+		ref := r.PathValue("ref")
+		if ref == "" {
+			ref = git.DefaultBranch()
+		}
+
+		content, err := git.Blob(ref, filePath)
+		if err != nil {
+			renderError(w, tmpl, http.StatusNotFound, err.Error())
+			return
+		}
+		tmpl.ExecuteTemplate(w, "repo_blob.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Path":        filePath,
+			"Breadcrumb":  Breadcrumb(filePath),
+			"Content":     content,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "code",
+		}))
+	}
+}
+
+func Log(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		ref := r.PathValue("ref")
+		commits, err := git.Log(ref, 50)
+		if err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		data := map[string]any{
+			"User":        User(r),
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"CommitList":  commits,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "commits",
+		}
+		tmpl.ExecuteTemplate(w, "repo_log.html", data)
+	}
+}
+
+func Diff(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		ref := r.PathValue("ref")
+		commit, err := git.Diff(ref)
+		if err != nil || len(commit) == 0 {
+			renderError(w, tmpl, http.StatusNotFound, "commit not found")
+			return
+		}
+		if err := tmpl.ExecuteTemplate(w, "repo_commit.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Diff":        commit,
+			"Hash":        ref,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "commits",
+		})); err != nil {
+			slog.Error("failed to execute template", "error", err)
+		}
+	}
+}
+
+func NewRepoGet(tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		tmpl.ExecuteTemplate(w, "repo_new.html", map[string]any{"User": User(r)})
+	}
+}
+
+func NewRepoPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		name := strings.TrimSpace(r.FormValue("name"))
+		desc := strings.TrimSpace(r.FormValue("description"))
+		importURL := strings.TrimSpace(r.FormValue("import_url"))
+
+		if importURL != "" {
+			importedName, err := ImportRepo(cfg, importURL, desc)
+			if err != nil {
+				renderError(w, tmpl, http.StatusBadRequest, "failed to import: "+err.Error())
+				return
+			}
+			http.Redirect(w, r, "/"+importedName, http.StatusSeeOther)
+			return
+		}
+
+		if name == "" {
+			renderError(w, tmpl, http.StatusBadRequest, "repository name is required")
+			return
+		}
+		if err := InitRepo(cfg, name, desc); err != nil {
+			renderError(w, tmpl, http.StatusBadRequest, "failed to create repository: "+err.Error())
+			return
+		}
+		http.Redirect(w, r, "/"+name, http.StatusSeeOther)
+	}
+}
+
+func Refs(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		ref := git.DefaultBranch()
+		tmpl.ExecuteTemplate(w, "repo_refs.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Description": git.Description(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "refs",
+		}))
+	}
+}
+
+func SettingsGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		meta := git.LoadMeta()
+		ref := git.DefaultBranch()
+		tmpl.ExecuteTemplate(w, "repo_settings.html", map[string]any{
+			"Repo":              git.Repo(),
+			"Ref":               ref,
+			"IsPrivate":         meta.IsPrivate,
+			"Description":       meta.Description,
+			"Branches":          git.Branches(),
+			"Tags":              git.Tags(),
+			"Commits":           git.CommitCount(ref),
+			"AuthorizedKeys":    strings.Join(meta.AuthorizedKeys, "\n"),
+			"ProtectedBranches": strings.Join(meta.ProtectedBranches, "\n"),
+			"ActiveTab":         "settings",
+			"User":              User(r),
+		})
+	}
+}
+
+func SettingsPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		if User(r) == "" {
+			http.Redirect(w, r, "/login", http.StatusSeeOther)
+			return
+		}
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		meta := git.LoadMeta()
+		meta.Description = strings.TrimSpace(r.FormValue("description"))
+		meta.IsPrivate = r.FormValue("is_private") == "true"
+
+		// Parse authorized keys: one key per line, skip empty lines
+		rawKeys := strings.TrimSpace(r.FormValue("authorized_keys"))
+		if rawKeys != "" {
+			meta.AuthorizedKeys = nil
+			for _, k := range strings.Split(rawKeys, "\n") {
+				k = strings.TrimSpace(k)
+				if k != "" {
+					meta.AuthorizedKeys = append(meta.AuthorizedKeys, k)
+				}
+			}
+		} else {
+			meta.AuthorizedKeys = nil
+		}
+
+		// Parse protected branches
+		rawBranches := strings.TrimSpace(r.FormValue("protected_branches"))
+		if rawBranches != "" {
+			meta.ProtectedBranches = nil
+			for _, b := range strings.Split(rawBranches, "\n") {
+				b = strings.TrimSpace(b)
+				if b != "" {
+					meta.ProtectedBranches = append(meta.ProtectedBranches, b)
+				}
+			}
+		} else {
+			meta.ProtectedBranches = nil
+		}
+
+		if err := git.SaveMeta(meta); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, "failed to save settings: "+err.Error())
+			return
+		}
+		http.Redirect(w, r, "/"+git.Name+"/settings", http.StatusSeeOther)
+	}
+}
diff --git a/templates/auth_login.html b/templates/auth_login.html
new file mode 100644
index 0000000..c496a59
--- /dev/null
+++ b/templates/auth_login.html
@@ -0,0 +1,25 @@
+{{template "head" .}}
+{{define "title"}}sign in - fierj{{end}}
+
+<div class="auth-card">
+  <h1>Sign in to fierj</h1>
+
+  {{if .Error}}
+  <div class="alert-danger">
+    {{.Error}}
+  </div>
+  {{end}}
+
+  <form method="post">
+    <div class="form-group">
+      <label for="username">Username</label>
+      <input type="text" id="username" name="username" autofocus required>
+    </div>
+    <div class="form-group">
+      <label for="password">Password</label>
+      <input type="password" id="password" name="password" required>
+    </div>
+    <button type="submit" class="btn">Sign in</button>
+  </form>
+</div>
+{{template "foot" .}}
diff --git a/templates/auth_setup.html b/templates/auth_setup.html
new file mode 100644
index 0000000..43e8b00
--- /dev/null
+++ b/templates/auth_setup.html
@@ -0,0 +1,32 @@
+{{template "head" .}}
+{{define "title"}}setup - fierj{{end}}
+
+<div class="auth-card">
+  <h1 style="margin-bottom:var(--space-sm);">Welcome to fierj</h1>
+  <p class="subtitle">
+    Create the first admin account to get started.
+  </p>
+
+  {{if .Error}}
+  <div class="alert-danger">
+    {{.Error}}
+  </div>
+  {{end}}
+
+  <form method="post">
+    <div class="form-group">
+      <label for="username">Username</label>
+      <input type="text" id="username" name="username" autofocus required>
+    </div>
+    <div class="form-group">
+      <label for="password">Password</label>
+      <input type="password" id="password" name="password" required minlength="6">
+    </div>
+    <div class="form-group">
+      <label for="confirm">Confirm password</label>
+      <input type="password" id="confirm" name="confirm" required minlength="6">
+    </div>
+    <button type="submit" class="btn">Create account</button>
+  </form>
+</div>
+{{template "foot" .}}
diff --git a/templates/auth_users.html b/templates/auth_users.html
new file mode 100644
index 0000000..c3392fd
--- /dev/null
+++ b/templates/auth_users.html
@@ -0,0 +1,58 @@
+{{template "head" .}}
+{{define "title"}}users - fierj{{end}}
+
+<h1 class="page-header">Users</h1>
+
+<div class="grid-2">
+  <!-- Add user -->
+  <div class="card">
+    <h2>Add user</h2>
+    <form method="post">
+      <input type="hidden" name="action" value="add">
+      <div class="form-group">
+        <label for="username">Username</label>
+        <input type="text" id="username" name="username" required>
+      </div>
+      <div class="form-group">
+        <label for="password">Password</label>
+        <input type="password" id="password" name="password" required minlength="6">
+      </div>
+      <button type="submit" class="btn">Add</button>
+    </form>
+  </div>
+
+  <!-- Change own password -->
+  <div class="card">
+    <h2>Change your password</h2>
+    <form method="post">
+      <input type="hidden" name="action" value="change-password">
+      <div class="form-group">
+        <label for="new-password">New password</label>
+        <input type="password" id="new-password" name="password" required minlength="6">
+      </div>
+      <button type="submit" class="btn">Update</button>
+    </form>
+  </div>
+</div>
+
+<!-- User list -->
+<div class="mt-xl">
+  <h2 class="section-title">All users ({{len .Users}})</h2>
+  <div class="list-box">
+    {{range .Users}}
+    <div class="list-row">
+      <span>{{.}}</span>
+      {{if ne . $.User}}
+      <form method="post" onsubmit="return confirm('Remove {{.}}?')">
+        <input type="hidden" name="action" value="remove">
+        <input type="hidden" name="username" value="{{.}}">
+        <button type="submit" class="btn-subtle btn-sm">Remove</button>
+      </form>
+      {{else}}
+      <span class="tag-self">you</span>
+      {{end}}
+    </div>
+    {{end}}
+  </div>
+</div>
+{{template "foot" .}}
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..20c1d72
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,1214 @@
+{{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;
+        }
+
+        /* Default icon size; contexts below override where needed
+           (branch-picker/clone-btn use 14px, visibility badges 12px). */
+        .icon {
+            width: 16px;
+            height: 16px;
+            flex-shrink: 0;
+            vertical-align: text-bottom;
+        }
+
+        /* 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 .nav-link,
+        .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 {
+            display: inline-flex;
+            align-items: center;
+            gap: 4px;
+            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);
+        }
+
+        .topbar .nav-link-muted {
+            opacity: 0.7;
+        }
+
+        .topbar .nav-form {
+            display: inline;
+        }
+
+        .topbar .nav-form .nav-link {
+            background: none;
+            border: none;
+            cursor: pointer;
+            font-size: 0.85rem;
+            font-family: inherit;
+        }
+
+        /* 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;
+        }
+
+        .visibility {
+            display: inline-flex;
+            align-items: center;
+            gap: 0.3em;
+            font-size: 0.7rem;
+            border: 1px solid var(--border);
+            border-radius: 2em;
+            padding: 0.1rem 0.5rem;
+            color: var(--text-secondary);
+            font-weight: 500;
+        }
+
+        .visibility .icon {
+            width: 12px;
+            height: 12px;
+        }
+
+        .visibility-private {
+            color: var(--text);
+            border-color: var(--border);
+            background: var(--bg-inset);
+        }
+
+        .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;
+        }
+
+        .code-bar .breadcrumb {
+            margin-bottom: 0;
+        }
+
+        .clone-btn.icon-btn {
+            text-decoration: none;
+        }
+
+        .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);
+        }
+
+        .clone-btn .icon {
+            width: 14px;
+            height: 14px;
+        }
+
+        /* 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:not([type="checkbox"]):not([type="radio"]),
+        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:not([type="checkbox"]):not([type="radio"]):focus,
+        textarea:focus {
+            outline: none;
+            border-color: var(--accent);
+            box-shadow: 0 0 0 3px var(--accent-subtle);
+        }
+
+        input[type="checkbox"],
+        input[type="radio"] {
+            width: 1rem;
+            height: 1rem;
+            flex-shrink: 0;
+            accent-color: var(--accent);
+        }
+
+        label {
+            font-size: 0.85rem;
+            font-weight: 600;
+            display: block;
+            margin-bottom: var(--space-xs);
+        }
+
+        .form-group {
+            margin-bottom: var(--space-md);
+        }
+
+        .form-narrow {
+            max-width: 600px;
+        }
+
+        /* Auth pages (login/setup) */
+        .auth-card {
+            max-width: 360px;
+            margin: var(--space-xl) auto;
+        }
+
+        .auth-card h1 {
+            margin-bottom: var(--space-lg);
+            text-align: center;
+        }
+
+        .auth-card .subtitle {
+            text-align: center;
+            color: var(--text-secondary);
+            margin-bottom: var(--space-lg);
+        }
+
+        .auth-card .btn {
+            width: 100%;
+            justify-content: center;
+        }
+
+        .alert-danger {
+            background: var(--danger);
+            color: #fff;
+            padding: var(--space-sm) var(--space-md);
+            border-radius: var(--radius);
+            margin-bottom: var(--space-md);
+            font-size: 0.85rem;
+        }
+
+        /* Error page */
+        .error-page {
+            text-align: center;
+            padding: 3rem 1rem;
+        }
+
+        .error-page h1 {
+            font-size: 3rem;
+            font-weight: 300;
+            color: var(--text-secondary);
+            margin-bottom: 0.5rem;
+        }
+
+        .error-page p {
+            font-size: 1.1rem;
+            color: var(--text-secondary);
+            margin-bottom: 1.5rem;
+        }
+
+        .error-page .btn {
+            display: inline-flex;
+        }
+
+        /* Checkbox / radio row: input and its text sit side by side,
+           unlike a regular label which stacks above its field */
+        .form-check {
+            display: flex;
+            align-items: center;
+            gap: var(--space-sm);
+            font-size: 0.9rem;
+            font-weight: 400;
+            margin: 0;
+            cursor: pointer;
+        }
+
+        .hint {
+            font-size: 0.8rem;
+            color: var(--text-secondary);
+            margin-top: var(--space-xs);
+        }
+
+        .mono-input {
+            font-family: var(--font-mono);
+            font-size: 0.8rem;
+        }
+
+        /* 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;
+        }
+
+        /* Cards & simple layout grids (users.html, etc.) */
+        .card {
+            border: 1px solid var(--border);
+            border-radius: var(--radius);
+            padding: var(--space-lg);
+            background: var(--bg);
+        }
+
+        .card h2,
+        .section-title {
+            font-size: 1rem;
+            margin-bottom: var(--space-md);
+        }
+
+        .grid-2 {
+            display: grid;
+            grid-template-columns: 1fr 1fr;
+            gap: var(--space-xl);
+        }
+
+        .list-box {
+            border: 1px solid var(--border);
+            border-radius: var(--radius);
+            background: var(--bg);
+        }
+
+        .list-row {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: var(--space-sm) var(--space-md);
+            border-bottom: 1px solid var(--border-subtle);
+        }
+
+        .list-row:last-child {
+            border-bottom: none;
+        }
+
+        .list-row .tag-self {
+            font-size: 0.8rem;
+            color: var(--text-tertiary);
+        }
+
+        .btn-sm {
+            padding: 2px 10px;
+            font-size: 0.8rem;
+        }
+
+        .mt-xl {
+            margin-top: var(--space-xl);
+        }
+
+        @media (max-width: 600px) {
+            .grid-2 {
+                grid-template-columns: 1fr;
+            }
+        }
+
+        /* Repo list (repos.html) */
+        .page-header {
+            margin-bottom: var(--space-lg);
+            font-size: 1.5rem;
+        }
+
+        .repo-list {
+            display: grid;
+            gap: var(--space-md);
+        }
+
+        .repo-card {
+            border: 1px solid var(--border);
+            border-radius: var(--radius);
+            padding: var(--space-md) var(--space-lg);
+            background: var(--bg);
+        }
+
+        .repo-card-header {
+            display: flex;
+            align-items: center;
+            gap: var(--space-sm);
+        }
+
+        .repo-card-header > .icon {
+            width: 16px;
+            height: 16px;
+            color: var(--text-secondary);
+            flex-shrink: 0;
+        }
+
+        .repo-card-header a {
+            font-weight: 600;
+            font-size: 1rem;
+        }
+
+        .repo-card-desc {
+            color: var(--text-secondary);
+            margin-top: var(--space-xs);
+            font-size: 0.85rem;
+        }
+
+        /* 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);
+            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;
+        }
+
+        .icon-ref {
+            color: var(--text-secondary);
+            margin-top: 2px;
+        }
+
+        .thread-list-content {
+            flex: 1;
+            min-width: 0;
+            overflow: hidden;
+        }
+
+        .thread-title {
+            font-weight: 600;
+            color: var(--text);
+            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-subtle);
+            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);
+            color: var(--text);
+            resize: vertical;
+        }
+
+        .thread-actions {
+            display: flex;
+            gap: var(--space-sm);
+            margin-top: var(--space-sm);
+        }
+
+        .btn-secondary {
+            background: var(--bg-subtle);
+            color: var(--text);
+            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-subtle);
+            border-radius: var(--radius);
+        }
+
+        .patch-branches-hint code {
+            background: var(--bg);
+            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">
+            <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
+            New
+        </a>
+        {{if .User}}
+        <a class="nav-link" href="/users">Users</a>
+        <span class="nav-link nav-link-muted">{{.User}}</span>
+        <form method="post" action="/logout" class="nav-form">
+            <button type="submit" class="nav-link">Sign out</button>
+        </form>
+        {{else}}
+        <a class="nav-link" href="/login">Sign in</a>
+        {{end}}
+        <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 id="hljs-theme" rel="stylesheet"
+        href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css">
+    <script>
+        function hljsHref(theme) {
+            return theme === 'light'
+                ? 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css'
+                : 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css';
+        }
+        function resolvedTheme() {
+            const attr = document.documentElement.getAttribute('data-theme');
+            if (attr) return attr;
+            return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+        }
+        function toggleTheme() {
+            const html = document.documentElement;
+            const current = html.getAttribute('data-theme');
+            const next = current === 'dark' ? 'light' : (current === 'light' ? 'dark' :
+                (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark'));
+            html.setAttribute('data-theme', next);
+            localStorage.setItem('theme', next);
+            document.getElementById('hljs-theme').href = hljsHref(next);
+        }
+        (function () {
+            const saved = localStorage.getItem('theme');
+            if (saved) document.documentElement.setAttribute('data-theme', saved);
+            document.getElementById('hljs-theme').href = hljsHref(resolvedTheme());
+        })();
+        document.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
+        document.querySelectorAll('.markdown-body').forEach(el => {
+            el.innerHTML = marked.parse(el.textContent);
+            el.classList.add('rendered-md');
+            el.querySelectorAll('pre code').forEach(c => hljs.highlightElement(c));
+        });
+    </script>
+</body>
+
+</html>
+{{end}}
+
diff --git a/templates/blob.html b/templates/blob.html
deleted file mode 100644
index ed71103..0000000
--- a/templates/blob.html
+++ /dev/null
@@ -1,18 +0,0 @@
-{{template "head" .}}
-{{define "title"}}{{.Path}} - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-<div style="margin-bottom:var(--space-md);font-size:0.85rem;color:var(--text-secondary);">
-    <a href="/{{.Repo}}/tree/{{.Ref}}">← {{.Repo}}</a>
-    {{range .Breadcrumb}} / <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.URL}}">{{.Name}}</a>{{end}}
-</div>
-{{if or (hasSuffix .Path ".md") (hasSuffix .Path ".markdown")}}
-<div class="readme-box">
-    <div class="readme-box-header">{{.Path}}</div>
-    <div class="readme-box-body markdown-body">{{.Content}}</div>
-</div>
-{{else}}
-<pre><code>{{.Content}}</code></pre>
-{{end}}
-{{template "foot" .}}
-
diff --git a/templates/commit.html b/templates/commit.html
deleted file mode 100644
index 09d9df9..0000000
--- a/templates/commit.html
+++ /dev/null
@@ -1,10 +0,0 @@
-{{template "head" .}}
-{{define "title"}}{{shortHash .Hash}} - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-<p class="breadcrumb">
-    <a href="/{{.Repo}}/log/{{.Ref}}">← Commits</a> &middot; <code>{{shortHash .Hash}}</code>
-</p>
-<pre><code>{{.Diff}}</code></pre>
-{{template "foot" .}}
-
diff --git a/templates/components.html b/templates/components.html
new file mode 100644
index 0000000..9aedaa5
--- /dev/null
+++ b/templates/components.html
@@ -0,0 +1,76 @@
+{{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>
+    {{if .IsPrivate}}
+    <span class="visibility visibility-private">
+        <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/></svg>
+        Private
+    </span>
+    {{else}}
+    <span class="visibility">Public</span>
+    {{end}}
+</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="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z" />
+        </svg>
+        Commits
+        {{if .Commits}}<span class="count">{{.Commits}}</span>{{end}}
+    </a>
+    <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
+        {{if or .Branches .Tags}}<span class="count">{{len .Branches}}{{if .Tags}}+{{len .Tags}}{{end}}</span>{{end}}
+    </a>
+    <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-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
+        </svg>
+        Patches
+    </a>
+    {{if $.User}}
+    <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 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/>
+        </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 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
+        </svg>
+        Settings
+    </a>
+    {{end}}
+</nav>
+{{end}}
+
diff --git a/templates/layout.html b/templates/layout.html
deleted file mode 100644
index 20c1d72..0000000
--- a/templates/layout.html
+++ /dev/null
@@ -1,1214 +0,0 @@
-{{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;
-        }
-
-        /* Default icon size; contexts below override where needed
-           (branch-picker/clone-btn use 14px, visibility badges 12px). */
-        .icon {
-            width: 16px;
-            height: 16px;
-            flex-shrink: 0;
-            vertical-align: text-bottom;
-        }
-
-        /* 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 .nav-link,
-        .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 {
-            display: inline-flex;
-            align-items: center;
-            gap: 4px;
-            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);
-        }
-
-        .topbar .nav-link-muted {
-            opacity: 0.7;
-        }
-
-        .topbar .nav-form {
-            display: inline;
-        }
-
-        .topbar .nav-form .nav-link {
-            background: none;
-            border: none;
-            cursor: pointer;
-            font-size: 0.85rem;
-            font-family: inherit;
-        }
-
-        /* 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;
-        }
-
-        .visibility {
-            display: inline-flex;
-            align-items: center;
-            gap: 0.3em;
-            font-size: 0.7rem;
-            border: 1px solid var(--border);
-            border-radius: 2em;
-            padding: 0.1rem 0.5rem;
-            color: var(--text-secondary);
-            font-weight: 500;
-        }
-
-        .visibility .icon {
-            width: 12px;
-            height: 12px;
-        }
-
-        .visibility-private {
-            color: var(--text);
-            border-color: var(--border);
-            background: var(--bg-inset);
-        }
-
-        .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;
-        }
-
-        .code-bar .breadcrumb {
-            margin-bottom: 0;
-        }
-
-        .clone-btn.icon-btn {
-            text-decoration: none;
-        }
-
-        .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);
-        }
-
-        .clone-btn .icon {
-            width: 14px;
-            height: 14px;
-        }
-
-        /* 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:not([type="checkbox"]):not([type="radio"]),
-        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:not([type="checkbox"]):not([type="radio"]):focus,
-        textarea:focus {
-            outline: none;
-            border-color: var(--accent);
-            box-shadow: 0 0 0 3px var(--accent-subtle);
-        }
-
-        input[type="checkbox"],
-        input[type="radio"] {
-            width: 1rem;
-            height: 1rem;
-            flex-shrink: 0;
-            accent-color: var(--accent);
-        }
-
-        label {
-            font-size: 0.85rem;
-            font-weight: 600;
-            display: block;
-            margin-bottom: var(--space-xs);
-        }
-
-        .form-group {
-            margin-bottom: var(--space-md);
-        }
-
-        .form-narrow {
-            max-width: 600px;
-        }
-
-        /* Auth pages (login/setup) */
-        .auth-card {
-            max-width: 360px;
-            margin: var(--space-xl) auto;
-        }
-
-        .auth-card h1 {
-            margin-bottom: var(--space-lg);
-            text-align: center;
-        }
-
-        .auth-card .subtitle {
-            text-align: center;
-            color: var(--text-secondary);
-            margin-bottom: var(--space-lg);
-        }
-
-        .auth-card .btn {
-            width: 100%;
-            justify-content: center;
-        }
-
-        .alert-danger {
-            background: var(--danger);
-            color: #fff;
-            padding: var(--space-sm) var(--space-md);
-            border-radius: var(--radius);
-            margin-bottom: var(--space-md);
-            font-size: 0.85rem;
-        }
-
-        /* Error page */
-        .error-page {
-            text-align: center;
-            padding: 3rem 1rem;
-        }
-
-        .error-page h1 {
-            font-size: 3rem;
-            font-weight: 300;
-            color: var(--text-secondary);
-            margin-bottom: 0.5rem;
-        }
-
-        .error-page p {
-            font-size: 1.1rem;
-            color: var(--text-secondary);
-            margin-bottom: 1.5rem;
-        }
-
-        .error-page .btn {
-            display: inline-flex;
-        }
-
-        /* Checkbox / radio row: input and its text sit side by side,
-           unlike a regular label which stacks above its field */
-        .form-check {
-            display: flex;
-            align-items: center;
-            gap: var(--space-sm);
-            font-size: 0.9rem;
-            font-weight: 400;
-            margin: 0;
-            cursor: pointer;
-        }
-
-        .hint {
-            font-size: 0.8rem;
-            color: var(--text-secondary);
-            margin-top: var(--space-xs);
-        }
-
-        .mono-input {
-            font-family: var(--font-mono);
-            font-size: 0.8rem;
-        }
-
-        /* 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;
-        }
-
-        /* Cards & simple layout grids (users.html, etc.) */
-        .card {
-            border: 1px solid var(--border);
-            border-radius: var(--radius);
-            padding: var(--space-lg);
-            background: var(--bg);
-        }
-
-        .card h2,
-        .section-title {
-            font-size: 1rem;
-            margin-bottom: var(--space-md);
-        }
-
-        .grid-2 {
-            display: grid;
-            grid-template-columns: 1fr 1fr;
-            gap: var(--space-xl);
-        }
-
-        .list-box {
-            border: 1px solid var(--border);
-            border-radius: var(--radius);
-            background: var(--bg);
-        }
-
-        .list-row {
-            display: flex;
-            align-items: center;
-            justify-content: space-between;
-            padding: var(--space-sm) var(--space-md);
-            border-bottom: 1px solid var(--border-subtle);
-        }
-
-        .list-row:last-child {
-            border-bottom: none;
-        }
-
-        .list-row .tag-self {
-            font-size: 0.8rem;
-            color: var(--text-tertiary);
-        }
-
-        .btn-sm {
-            padding: 2px 10px;
-            font-size: 0.8rem;
-        }
-
-        .mt-xl {
-            margin-top: var(--space-xl);
-        }
-
-        @media (max-width: 600px) {
-            .grid-2 {
-                grid-template-columns: 1fr;
-            }
-        }
-
-        /* Repo list (repos.html) */
-        .page-header {
-            margin-bottom: var(--space-lg);
-            font-size: 1.5rem;
-        }
-
-        .repo-list {
-            display: grid;
-            gap: var(--space-md);
-        }
-
-        .repo-card {
-            border: 1px solid var(--border);
-            border-radius: var(--radius);
-            padding: var(--space-md) var(--space-lg);
-            background: var(--bg);
-        }
-
-        .repo-card-header {
-            display: flex;
-            align-items: center;
-            gap: var(--space-sm);
-        }
-
-        .repo-card-header > .icon {
-            width: 16px;
-            height: 16px;
-            color: var(--text-secondary);
-            flex-shrink: 0;
-        }
-
-        .repo-card-header a {
-            font-weight: 600;
-            font-size: 1rem;
-        }
-
-        .repo-card-desc {
-            color: var(--text-secondary);
-            margin-top: var(--space-xs);
-            font-size: 0.85rem;
-        }
-
-        /* 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);
-            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;
-        }
-
-        .icon-ref {
-            color: var(--text-secondary);
-            margin-top: 2px;
-        }
-
-        .thread-list-content {
-            flex: 1;
-            min-width: 0;
-            overflow: hidden;
-        }
-
-        .thread-title {
-            font-weight: 600;
-            color: var(--text);
-            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-subtle);
-            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);
-            color: var(--text);
-            resize: vertical;
-        }
-
-        .thread-actions {
-            display: flex;
-            gap: var(--space-sm);
-            margin-top: var(--space-sm);
-        }
-
-        .btn-secondary {
-            background: var(--bg-subtle);
-            color: var(--text);
-            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-subtle);
-            border-radius: var(--radius);
-        }
-
-        .patch-branches-hint code {
-            background: var(--bg);
-            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">
-            <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></svg>
-            New
-        </a>
-        {{if .User}}
-        <a class="nav-link" href="/users">Users</a>
-        <span class="nav-link nav-link-muted">{{.User}}</span>
-        <form method="post" action="/logout" class="nav-form">
-            <button type="submit" class="nav-link">Sign out</button>
-        </form>
-        {{else}}
-        <a class="nav-link" href="/login">Sign in</a>
-        {{end}}
-        <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 id="hljs-theme" rel="stylesheet"
-        href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css">
-    <script>
-        function hljsHref(theme) {
-            return theme === 'light'
-                ? 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css'
-                : 'https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github-dark.min.css';
-        }
-        function resolvedTheme() {
-            const attr = document.documentElement.getAttribute('data-theme');
-            if (attr) return attr;
-            return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
-        }
-        function toggleTheme() {
-            const html = document.documentElement;
-            const current = html.getAttribute('data-theme');
-            const next = current === 'dark' ? 'light' : (current === 'light' ? 'dark' :
-                (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'light' : 'dark'));
-            html.setAttribute('data-theme', next);
-            localStorage.setItem('theme', next);
-            document.getElementById('hljs-theme').href = hljsHref(next);
-        }
-        (function () {
-            const saved = localStorage.getItem('theme');
-            if (saved) document.documentElement.setAttribute('data-theme', saved);
-            document.getElementById('hljs-theme').href = hljsHref(resolvedTheme());
-        })();
-        document.querySelectorAll('pre code').forEach(el => hljs.highlightElement(el));
-        document.querySelectorAll('.markdown-body').forEach(el => {
-            el.innerHTML = marked.parse(el.textContent);
-            el.classList.add('rendered-md');
-            el.querySelectorAll('pre code').forEach(c => hljs.highlightElement(c));
-        });
-    </script>
-</body>
-
-</html>
-{{end}}
-
diff --git a/templates/log.html b/templates/log.html
deleted file mode 100644
index 418340a..0000000
--- a/templates/log.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{{template "head" .}}
-{{define "title"}}commits - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-<div class="commit-list">
-{{range .CommitList}}
-<div class="commit-list-item">
-    <span class="msg"><a href="/{{$.Repo}}/commit/{{.Hash}}">{{.Message}}</a></span>
-    <span class="meta">{{.Author}}</span>
-    <span class="hash"><a href="/{{$.Repo}}/commit/{{.Hash}}">{{shortHash .Hash}}</a></span>
-    <span class="meta">{{.Date}}</span>
-</div>
-{{end}}
-</div>
-{{template "foot" .}}
-
diff --git a/templates/login.html b/templates/login.html
deleted file mode 100644
index c496a59..0000000
--- a/templates/login.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{{template "head" .}}
-{{define "title"}}sign in - fierj{{end}}
-
-<div class="auth-card">
-  <h1>Sign in to fierj</h1>
-
-  {{if .Error}}
-  <div class="alert-danger">
-    {{.Error}}
-  </div>
-  {{end}}
-
-  <form method="post">
-    <div class="form-group">
-      <label for="username">Username</label>
-      <input type="text" id="username" name="username" autofocus required>
-    </div>
-    <div class="form-group">
-      <label for="password">Password</label>
-      <input type="password" id="password" name="password" required>
-    </div>
-    <button type="submit" class="btn">Sign in</button>
-  </form>
-</div>
-{{template "foot" .}}
diff --git a/templates/new_repo.html b/templates/new_repo.html
deleted file mode 100644
index 5f88440..0000000
--- a/templates/new_repo.html
+++ /dev/null
@@ -1,25 +0,0 @@
-{{template "head" .}}
-{{define "title"}}new repository - fierj{{end}}
-<h1 class="page-header">New Repository</h1>
-<form method="post" class="form-narrow">
-  <div class="form-group">
-    <label for="import_url">Import from URL (optional)</label>
-    <input type="text" id="import_url" name="import_url" placeholder="https://github.com/user/repo.git">
-    <div class="hint">
-      Leave blank to create an empty repository.
-    </div>
-  </div>
-  <div class="form-group">
-    <label for="name">Repository name</label>
-    <input type="text" id="name" name="name" placeholder="my-project" {{if eq .FromImport "true"}}value="{{.SuggestedName}}"{{end}}>
-    <div class="hint">
-      Required unless importing &mdash; then it's derived from the URL.
-    </div>
-  </div>
-  <div class="form-group">
-    <label for="description">Description (optional)</label>
-    <input type="text" id="description" name="description" placeholder="A short description">
-  </div>
-  <button type="submit" class="btn">Create repository</button>
-</form>
-{{template "foot" .}}
diff --git a/templates/partials.html b/templates/partials.html
deleted file mode 100644
index 9aedaa5..0000000
--- a/templates/partials.html
+++ /dev/null
@@ -1,76 +0,0 @@
-{{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>
-    {{if .IsPrivate}}
-    <span class="visibility visibility-private">
-        <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/></svg>
-        Private
-    </span>
-    {{else}}
-    <span class="visibility">Public</span>
-    {{end}}
-</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="m.427 1.927 1.215 1.215a8.002 8.002 0 1 1-1.6 5.685.75.75 0 1 1 1.493-.154 6.5 6.5 0 1 0 1.18-4.458l1.358 1.358A.25.25 0 0 1 3.896 6H.25A.25.25 0 0 1 0 5.75V2.104a.25.25 0 0 1 .427-.177ZM7.75 4a.75.75 0 0 1 .75.75v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5A.75.75 0 0 1 7.75 4Z" />
-        </svg>
-        Commits
-        {{if .Commits}}<span class="count">{{.Commits}}</span>{{end}}
-    </a>
-    <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
-        {{if or .Branches .Tags}}<span class="count">{{len .Branches}}{{if .Tags}}+{{len .Tags}}{{end}}</span>{{end}}
-    </a>
-    <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-.354ZM3.75 2.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm0 9.5a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Zm8.25.75a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Z" />
-        </svg>
-        Patches
-    </a>
-    {{if $.User}}
-    <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 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/>
-        </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 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294.016.257.016.515 0 .772-.01.147.038.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/>
-        </svg>
-        Settings
-    </a>
-    {{end}}
-</nav>
-{{end}}
-
diff --git a/templates/patch_list.html b/templates/patch_list.html
new file mode 100644
index 0000000..a52040e
--- /dev/null
+++ b/templates/patch_list.html
@@ -0,0 +1,54 @@
+{{template "head" .}}
+{{define "title"}}patches - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="threads-header">
+    <div class="threads-state-toggle">
+        <a href="/{{.Repo}}/patches?state=open" {{if eq .State "open" }} class="active" {{end}}>Open</a>
+        <a href="/{{.Repo}}/patches?state=merged" {{if eq .State "merged" }} class="active" {{end}}>Merged</a>
+        <a href="/{{.Repo}}/patches?state=closed" {{if eq .State "closed" }} class="active" {{end}}>Closed</a>
+    </div>
+    <a href="/{{.Repo}}/patches/new" class="btn btn-primary">New patch</a>
+</div>
+
+{{if .FreeBranches}}
+<div class="patch-branches-hint">
+    <strong>Branches available for merge:</strong>
+    {{range .FreeBranches}}<code>{{.}}</code> {{end}}
+</div>
+{{end}}
+
+{{if .Patches}}
+<div class="thread-list">
+    {{range .Patches}}
+    <div class="thread-list-item">
+        <svg class="icon thread-icon-{{.State}}" viewBox="0 0 16 16" fill="currentColor">
+            {{if eq .State "open"}}
+            <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" />
+            {{else if eq .State "merged"}}
+            <path
+                d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218Z" />
+            {{else}}
+            <path
+                d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06L12 3.56v2.69a.75.75 0 0 1-1.5 0V3.56l-.72.72a.75.75 0 0 1-1.06-1.06Z" />
+            {{end}}
+        </svg>
+        <div class="thread-list-content">
+            <a href="/{{$.Repo}}/patches/{{.ID}}" class="thread-title">{{.Title}}</a>
+            <span class="thread-meta">
+                {{if .Branch}}branch: <code>{{.Branch}}</code> · {{end}}
+                {{.AuthorName}} · {{timeAgo .Updated}}
+            </span>
+        </div>
+    </div>
+    {{end}}
+</div>
+{{else}}
+<div class="empty-state">
+    <p>No {{.State}} patches.</p>
+</div>
+{{end}}
+{{template "foot" .}}
+
diff --git a/templates/patches.html b/templates/patches.html
deleted file mode 100644
index a52040e..0000000
--- a/templates/patches.html
+++ /dev/null
@@ -1,54 +0,0 @@
-{{template "head" .}}
-{{define "title"}}patches - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-
-<div class="threads-header">
-    <div class="threads-state-toggle">
-        <a href="/{{.Repo}}/patches?state=open" {{if eq .State "open" }} class="active" {{end}}>Open</a>
-        <a href="/{{.Repo}}/patches?state=merged" {{if eq .State "merged" }} class="active" {{end}}>Merged</a>
-        <a href="/{{.Repo}}/patches?state=closed" {{if eq .State "closed" }} class="active" {{end}}>Closed</a>
-    </div>
-    <a href="/{{.Repo}}/patches/new" class="btn btn-primary">New patch</a>
-</div>
-
-{{if .FreeBranches}}
-<div class="patch-branches-hint">
-    <strong>Branches available for merge:</strong>
-    {{range .FreeBranches}}<code>{{.}}</code> {{end}}
-</div>
-{{end}}
-
-{{if .Patches}}
-<div class="thread-list">
-    {{range .Patches}}
-    <div class="thread-list-item">
-        <svg class="icon thread-icon-{{.State}}" viewBox="0 0 16 16" fill="currentColor">
-            {{if eq .State "open"}}
-            <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" />
-            {{else if eq .State "merged"}}
-            <path
-                d="M5.45 5.154A4.25 4.25 0 0 0 9.25 7.5h1.378a2.251 2.251 0 1 1 0 1.5H9.25A5.734 5.734 0 0 1 5 7.123v3.505a2.25 2.25 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.95-.218Z" />
-            {{else}}
-            <path
-                d="M3.25 1A2.25 2.25 0 0 1 4 5.372v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 3.25 1Zm9.5 5.5a.75.75 0 0 1 .75.75v3.378a2.251 2.251 0 1 1-1.5 0V7.25a.75.75 0 0 1 .75-.75Zm-2.03-5.273a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1-1.06 1.06L12 3.56v2.69a.75.75 0 0 1-1.5 0V3.56l-.72.72a.75.75 0 0 1-1.06-1.06Z" />
-            {{end}}
-        </svg>
-        <div class="thread-list-content">
-            <a href="/{{$.Repo}}/patches/{{.ID}}" class="thread-title">{{.Title}}</a>
-            <span class="thread-meta">
-                {{if .Branch}}branch: <code>{{.Branch}}</code> · {{end}}
-                {{.AuthorName}} · {{timeAgo .Updated}}
-            </span>
-        </div>
-    </div>
-    {{end}}
-</div>
-{{else}}
-<div class="empty-state">
-    <p>No {{.State}} patches.</p>
-</div>
-{{end}}
-{{template "foot" .}}
-
diff --git a/templates/refs.html b/templates/refs.html
deleted file mode 100644
index 446863d..0000000
--- a/templates/refs.html
+++ /dev/null
@@ -1,41 +0,0 @@
-{{template "head" .}}
-{{define "title"}}refs - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-
-<div class="refs-container">
-    {{if .Branches}}
-    <div class="refs-section">
-        <h3>Branches</h3>
-        <div class="thread-list">
-            {{range .Branches}}
-            <div class="thread-list-item">
-                <svg class="icon icon-ref" 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>
-                <div class="thread-list-content">
-                    <a href="/{{$.Repo}}/tree/{{.}}" class="thread-title">{{.}}</a>
-                    {{if eq . $.DefaultBranch}}<span class="label-default">default</span>{{end}}
-                </div>
-            </div>
-            {{end}}
-        </div>
-    </div>
-    {{end}}
-    {{if .Tags}}
-    <div class="refs-section">
-        <h3>Tags</h3>
-        <div class="thread-list">
-            {{range .Tags}}
-            <div class="thread-list-item">
-                <svg class="icon icon-ref" viewBox="0 0 16 16" fill="currentColor"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/></svg>
-                <div class="thread-list-content">
-                    <a href="/{{$.Repo}}/tree/{{.}}" class="thread-title">{{.}}</a>
-                </div>
-            </div>
-            {{end}}
-        </div>
-    </div>
-    {{end}}
-</div>
-
-{{template "foot" .}}
-
diff --git a/templates/repo_blob.html b/templates/repo_blob.html
new file mode 100644
index 0000000..ed71103
--- /dev/null
+++ b/templates/repo_blob.html
@@ -0,0 +1,18 @@
+{{template "head" .}}
+{{define "title"}}{{.Path}} - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+<div style="margin-bottom:var(--space-md);font-size:0.85rem;color:var(--text-secondary);">
+    <a href="/{{.Repo}}/tree/{{.Ref}}">← {{.Repo}}</a>
+    {{range .Breadcrumb}} / <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.URL}}">{{.Name}}</a>{{end}}
+</div>
+{{if or (hasSuffix .Path ".md") (hasSuffix .Path ".markdown")}}
+<div class="readme-box">
+    <div class="readme-box-header">{{.Path}}</div>
+    <div class="readme-box-body markdown-body">{{.Content}}</div>
+</div>
+{{else}}
+<pre><code>{{.Content}}</code></pre>
+{{end}}
+{{template "foot" .}}
+
diff --git a/templates/repo_commit.html b/templates/repo_commit.html
new file mode 100644
index 0000000..09d9df9
--- /dev/null
+++ b/templates/repo_commit.html
@@ -0,0 +1,10 @@
+{{template "head" .}}
+{{define "title"}}{{shortHash .Hash}} - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+<p class="breadcrumb">
+    <a href="/{{.Repo}}/log/{{.Ref}}">← Commits</a> &middot; <code>{{shortHash .Hash}}</code>
+</p>
+<pre><code>{{.Diff}}</code></pre>
+{{template "foot" .}}
+
diff --git a/templates/repo_list.html b/templates/repo_list.html
new file mode 100644
index 0000000..cefef3e
--- /dev/null
+++ b/templates/repo_list.html
@@ -0,0 +1,33 @@
+{{template "head" .}}
+{{define "title"}}repositories - fierj{{end}}
+<h1 class="page-header">Repositories</h1>
+{{if .Repos}}
+<div class="repo-list">
+    {{range .Repos}}
+    <div class="repo-card">
+        <div class="repo-card-header">
+            <svg class="icon" 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>
+            <a href="/{{.Name}}">{{.Name}}</a>
+            {{if .IsPrivate}}
+            <span class="visibility visibility-private">
+                <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/></svg>
+                Private
+            </span>
+            {{else}}
+            <span class="visibility">Public</span>
+            {{end}}
+        </div>
+        {{if .Description}}<p class="repo-card-desc">{{.Description}}</p>{{end}}
+    </div>
+    {{end}}
+</div>
+{{else}}
+<div class="empty-state">
+    <p>No repositories yet. Create your first one.</p>
+</div>
+{{end}}
+{{template "foot" .}}
+
diff --git a/templates/repo_log.html b/templates/repo_log.html
new file mode 100644
index 0000000..418340a
--- /dev/null
+++ b/templates/repo_log.html
@@ -0,0 +1,16 @@
+{{template "head" .}}
+{{define "title"}}commits - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+<div class="commit-list">
+{{range .CommitList}}
+<div class="commit-list-item">
+    <span class="msg"><a href="/{{$.Repo}}/commit/{{.Hash}}">{{.Message}}</a></span>
+    <span class="meta">{{.Author}}</span>
+    <span class="hash"><a href="/{{$.Repo}}/commit/{{.Hash}}">{{shortHash .Hash}}</a></span>
+    <span class="meta">{{.Date}}</span>
+</div>
+{{end}}
+</div>
+{{template "foot" .}}
+
diff --git a/templates/repo_new.html b/templates/repo_new.html
new file mode 100644
index 0000000..5f88440
--- /dev/null
+++ b/templates/repo_new.html
@@ -0,0 +1,25 @@
+{{template "head" .}}
+{{define "title"}}new repository - fierj{{end}}
+<h1 class="page-header">New Repository</h1>
+<form method="post" class="form-narrow">
+  <div class="form-group">
+    <label for="import_url">Import from URL (optional)</label>
+    <input type="text" id="import_url" name="import_url" placeholder="https://github.com/user/repo.git">
+    <div class="hint">
+      Leave blank to create an empty repository.
+    </div>
+  </div>
+  <div class="form-group">
+    <label for="name">Repository name</label>
+    <input type="text" id="name" name="name" placeholder="my-project" {{if eq .FromImport "true"}}value="{{.SuggestedName}}"{{end}}>
+    <div class="hint">
+      Required unless importing &mdash; then it's derived from the URL.
+    </div>
+  </div>
+  <div class="form-group">
+    <label for="description">Description (optional)</label>
+    <input type="text" id="description" name="description" placeholder="A short description">
+  </div>
+  <button type="submit" class="btn">Create repository</button>
+</form>
+{{template "foot" .}}
diff --git a/templates/repo_refs.html b/templates/repo_refs.html
new file mode 100644
index 0000000..446863d
--- /dev/null
+++ b/templates/repo_refs.html
@@ -0,0 +1,41 @@
+{{template "head" .}}
+{{define "title"}}refs - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="refs-container">
+    {{if .Branches}}
+    <div class="refs-section">
+        <h3>Branches</h3>
+        <div class="thread-list">
+            {{range .Branches}}
+            <div class="thread-list-item">
+                <svg class="icon icon-ref" 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>
+                <div class="thread-list-content">
+                    <a href="/{{$.Repo}}/tree/{{.}}" class="thread-title">{{.}}</a>
+                    {{if eq . $.DefaultBranch}}<span class="label-default">default</span>{{end}}
+                </div>
+            </div>
+            {{end}}
+        </div>
+    </div>
+    {{end}}
+    {{if .Tags}}
+    <div class="refs-section">
+        <h3>Tags</h3>
+        <div class="thread-list">
+            {{range .Tags}}
+            <div class="thread-list-item">
+                <svg class="icon icon-ref" viewBox="0 0 16 16" fill="currentColor"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/></svg>
+                <div class="thread-list-content">
+                    <a href="/{{$.Repo}}/tree/{{.}}" class="thread-title">{{.}}</a>
+                </div>
+            </div>
+            {{end}}
+        </div>
+    </div>
+    {{end}}
+</div>
+
+{{template "foot" .}}
+
diff --git a/templates/repo_settings.html b/templates/repo_settings.html
new file mode 100644
index 0000000..b791219
--- /dev/null
+++ b/templates/repo_settings.html
@@ -0,0 +1,39 @@
+{{template "head" .}}
+{{define "title"}}settings - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<h1 class="page-header" style="font-size:1.25rem;">Repository settings</h1>
+
+<form method="post" class="form-narrow">
+  <div class="form-group">
+    <label for="description">Description</label>
+    <input type="text" id="description" name="description" value="{{.Description}}">
+  </div>
+
+  <div class="form-group">
+    <label class="form-check">
+      <input type="checkbox" name="is_private" value="true" {{if .IsPrivate}}checked{{end}}>
+      Private repository (only visible to logged-in users)
+    </label>
+  </div>
+
+  <div class="form-group">
+    <label for="authorized_keys">Authorized SSH keys (one per line)</label>
+    <textarea id="authorized_keys" name="authorized_keys" rows="6" class="mono-input">{{.AuthorizedKeys}}</textarea>
+    <div class="hint">
+      Users with these keys can push to this repo. Leave empty to allow anyone with SSH access.
+    </div>
+  </div>
+
+  <div class="form-group">
+    <label for="protected_branches">Protected branches (one per line)</label>
+    <textarea id="protected_branches" name="protected_branches" rows="4" placeholder="main&#10;release/*" class="mono-input">{{.ProtectedBranches}}</textarea>
+    <div class="hint">
+      Branches that require authorized keys to push. Supports wildcards (e.g. <code>release/*</code>).
+    </div>
+  </div>
+
+  <button type="submit" class="btn">Save settings</button>
+</form>
+{{template "foot" .}}
diff --git a/templates/repo_tree.html b/templates/repo_tree.html
new file mode 100644
index 0000000..310d000
--- /dev/null
+++ b/templates/repo_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>
+    {{if .CloneSSH}}<pre><code>git remote add origin {{.CloneSSH}}
+git push -u origin main</code></pre>{{end}}
+</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">
+        <a href="/{{.Repo}}">{{.Repo}}</a>
+        {{range .Breadcrumb}} / <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.URL}}">{{.Name}}</a>{{end}}
+    </span>
+    {{end}}
+    <span class="spacer"></span>
+    <span class="clone-btn">
+        <svg class="icon" 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>
+        {{if .CloneSSH}}{{.CloneSSH}}{{end}}
+    </span>
+    <a href="/{{.Repo}}/feed.xml" title="RSS feed" class="clone-btn icon-btn">
+        <svg class="icon" 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" .}}
+
diff --git a/templates/repos.html b/templates/repos.html
deleted file mode 100644
index cefef3e..0000000
--- a/templates/repos.html
+++ /dev/null
@@ -1,33 +0,0 @@
-{{template "head" .}}
-{{define "title"}}repositories - fierj{{end}}
-<h1 class="page-header">Repositories</h1>
-{{if .Repos}}
-<div class="repo-list">
-    {{range .Repos}}
-    <div class="repo-card">
-        <div class="repo-card-header">
-            <svg class="icon" 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>
-            <a href="/{{.Name}}">{{.Name}}</a>
-            {{if .IsPrivate}}
-            <span class="visibility visibility-private">
-                <svg class="icon" viewBox="0 0 16 16" fill="currentColor"><path d="M4 4a4 4 0 0 1 8 0v2h.25c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 12.25 15h-8.5A1.75 1.75 0 0 1 2 13.25v-5.5C2 6.784 2.784 6 3.75 6H4Zm8.25 3.5h-8.5a.25.25 0 0 0-.25.25v5.5c0 .138.112.25.25.25h8.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25ZM10.5 6V4a2.5 2.5 0 1 0-5 0v2Z"/></svg>
-                Private
-            </span>
-            {{else}}
-            <span class="visibility">Public</span>
-            {{end}}
-        </div>
-        {{if .Description}}<p class="repo-card-desc">{{.Description}}</p>{{end}}
-    </div>
-    {{end}}
-</div>
-{{else}}
-<div class="empty-state">
-    <p>No repositories yet. Create your first one.</p>
-</div>
-{{end}}
-{{template "foot" .}}
-
diff --git a/templates/settings.html b/templates/settings.html
deleted file mode 100644
index b791219..0000000
--- a/templates/settings.html
+++ /dev/null
@@ -1,39 +0,0 @@
-{{template "head" .}}
-{{define "title"}}settings - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-
-<h1 class="page-header" style="font-size:1.25rem;">Repository settings</h1>
-
-<form method="post" class="form-narrow">
-  <div class="form-group">
-    <label for="description">Description</label>
-    <input type="text" id="description" name="description" value="{{.Description}}">
-  </div>
-
-  <div class="form-group">
-    <label class="form-check">
-      <input type="checkbox" name="is_private" value="true" {{if .IsPrivate}}checked{{end}}>
-      Private repository (only visible to logged-in users)
-    </label>
-  </div>
-
-  <div class="form-group">
-    <label for="authorized_keys">Authorized SSH keys (one per line)</label>
-    <textarea id="authorized_keys" name="authorized_keys" rows="6" class="mono-input">{{.AuthorizedKeys}}</textarea>
-    <div class="hint">
-      Users with these keys can push to this repo. Leave empty to allow anyone with SSH access.
-    </div>
-  </div>
-
-  <div class="form-group">
-    <label for="protected_branches">Protected branches (one per line)</label>
-    <textarea id="protected_branches" name="protected_branches" rows="4" placeholder="main&#10;release/*" class="mono-input">{{.ProtectedBranches}}</textarea>
-    <div class="hint">
-      Branches that require authorized keys to push. Supports wildcards (e.g. <code>release/*</code>).
-    </div>
-  </div>
-
-  <button type="submit" class="btn">Save settings</button>
-</form>
-{{template "foot" .}}
diff --git a/templates/setup.html b/templates/setup.html
deleted file mode 100644
index 43e8b00..0000000
--- a/templates/setup.html
+++ /dev/null
@@ -1,32 +0,0 @@
-{{template "head" .}}
-{{define "title"}}setup - fierj{{end}}
-
-<div class="auth-card">
-  <h1 style="margin-bottom:var(--space-sm);">Welcome to fierj</h1>
-  <p class="subtitle">
-    Create the first admin account to get started.
-  </p>
-
-  {{if .Error}}
-  <div class="alert-danger">
-    {{.Error}}
-  </div>
-  {{end}}
-
-  <form method="post">
-    <div class="form-group">
-      <label for="username">Username</label>
-      <input type="text" id="username" name="username" autofocus required>
-    </div>
-    <div class="form-group">
-      <label for="password">Password</label>
-      <input type="password" id="password" name="password" required minlength="6">
-    </div>
-    <div class="form-group">
-      <label for="confirm">Confirm password</label>
-      <input type="password" id="confirm" name="confirm" required minlength="6">
-    </div>
-    <button type="submit" class="btn">Create account</button>
-  </form>
-</div>
-{{template "foot" .}}
diff --git a/templates/thread_list.html b/templates/thread_list.html
new file mode 100644
index 0000000..3b52c9c
--- /dev/null
+++ b/templates/thread_list.html
@@ -0,0 +1,62 @@
+{{template "head" .}}
+{{define "title"}}threads - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="threads-header">
+    <div class="threads-state-toggle">
+        <a href="/{{.Repo}}/threads?state=open" {{if eq .State "open" }} class="active" {{end}}>
+            <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+                <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
+                <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
+            </svg>
+            {{.OpenCount}} Open
+        </a>
+        <a href="/{{.Repo}}/threads?state=closed" {{if eq .State "closed" }} class="active" {{end}}>
+            <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
+                <path
+                    d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0Z" />
+                <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
+            </svg>
+            {{.ClosedCount}} Closed
+        </a>
+    </div>
+    <a href="/{{.Repo}}/threads/new" class="btn btn-primary">New thread</a>
+</div>
+
+{{if .Threads}}
+<div class="thread-list">
+    {{range .Threads}}
+    <div class="thread-list-item">
+        <svg class="icon thread-icon-{{.State}}" viewBox="0 0 16 16" fill="currentColor">
+            {{if eq .State "open"}}
+            <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
+            <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
+            {{else}}
+            <path
+                d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0Z" />
+            <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />{{end}}
+        </svg>
+        <div class="thread-list-content">
+            <a href="/{{$.Repo}}/threads/{{.ID}}" class="thread-title">{{.Title}}</a>
+            <span class="thread-meta">opened by {{.AuthorName}} · {{timeAgo .Updated}}</span>
+        </div>
+        {{if .Replies}}
+        <span class="thread-reply-count">
+            <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>
+            {{len .Replies}}
+        </span>
+        {{end}}
+    </div>
+    {{end}}
+</div>
+{{else}}
+<div class="empty-state">
+    <p>No {{.State}} threads yet.</p>
+</div>
+{{end}}
+{{template "foot" .}}
+
diff --git a/templates/threads.html b/templates/threads.html
deleted file mode 100644
index 3b52c9c..0000000
--- a/templates/threads.html
+++ /dev/null
@@ -1,62 +0,0 @@
-{{template "head" .}}
-{{define "title"}}threads - {{.Repo}} - fierj{{end}}
-{{template "repo-header" .}}
-{{template "repo-tabs" .}}
-
-<div class="threads-header">
-    <div class="threads-state-toggle">
-        <a href="/{{.Repo}}/threads?state=open" {{if eq .State "open" }} class="active" {{end}}>
-            <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
-                <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
-                <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
-            </svg>
-            {{.OpenCount}} Open
-        </a>
-        <a href="/{{.Repo}}/threads?state=closed" {{if eq .State "closed" }} class="active" {{end}}>
-            <svg class="icon" viewBox="0 0 16 16" fill="currentColor">
-                <path
-                    d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0Z" />
-                <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />
-            </svg>
-            {{.ClosedCount}} Closed
-        </a>
-    </div>
-    <a href="/{{.Repo}}/threads/new" class="btn btn-primary">New thread</a>
-</div>
-
-{{if .Threads}}
-<div class="thread-list">
-    {{range .Threads}}
-    <div class="thread-list-item">
-        <svg class="icon thread-icon-{{.State}}" viewBox="0 0 16 16" fill="currentColor">
-            {{if eq .State "open"}}
-            <path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
-            <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z" />
-            {{else}}
-            <path
-                d="M11.28 6.78a.75.75 0 0 0-1.06-1.06L7.25 8.69 5.78 7.22a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0Z" />
-            <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0Zm-1.5 0a6.5 6.5 0 1 0-13 0 6.5 6.5 0 0 0 13 0Z" />{{end}}
-        </svg>
-        <div class="thread-list-content">
-            <a href="/{{$.Repo}}/threads/{{.ID}}" class="thread-title">{{.Title}}</a>
-            <span class="thread-meta">opened by {{.AuthorName}} · {{timeAgo .Updated}}</span>
-        </div>
-        {{if .Replies}}
-        <span class="thread-reply-count">
-            <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>
-            {{len .Replies}}
-        </span>
-        {{end}}
-    </div>
-    {{end}}
-</div>
-{{else}}
-<div class="empty-state">
-    <p>No {{.State}} threads yet.</p>
-</div>
-{{end}}
-{{template "foot" .}}
-
diff --git a/templates/tree.html b/templates/tree.html
deleted file mode 100644
index 310d000..0000000
--- a/templates/tree.html
+++ /dev/null
@@ -1,66 +0,0 @@
-{{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>
-    {{if .CloneSSH}}<pre><code>git remote add origin {{.CloneSSH}}
-git push -u origin main</code></pre>{{end}}
-</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">
-        <a href="/{{.Repo}}">{{.Repo}}</a>
-        {{range .Breadcrumb}} / <a href="/{{$.Repo}}/tree/{{$.Ref}}/{{.URL}}">{{.Name}}</a>{{end}}
-    </span>
-    {{end}}
-    <span class="spacer"></span>
-    <span class="clone-btn">
-        <svg class="icon" 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>
-        {{if .CloneSSH}}{{.CloneSSH}}{{end}}
-    </span>
-    <a href="/{{.Repo}}/feed.xml" title="RSS feed" class="clone-btn icon-btn">
-        <svg class="icon" 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" .}}
-
diff --git a/templates/users.html b/templates/users.html
deleted file mode 100644
index c3392fd..0000000
--- a/templates/users.html
+++ /dev/null
@@ -1,58 +0,0 @@
-{{template "head" .}}
-{{define "title"}}users - fierj{{end}}
-
-<h1 class="page-header">Users</h1>
-
-<div class="grid-2">
-  <!-- Add user -->
-  <div class="card">
-    <h2>Add user</h2>
-    <form method="post">
-      <input type="hidden" name="action" value="add">
-      <div class="form-group">
-        <label for="username">Username</label>
-        <input type="text" id="username" name="username" required>
-      </div>
-      <div class="form-group">
-        <label for="password">Password</label>
-        <input type="password" id="password" name="password" required minlength="6">
-      </div>
-      <button type="submit" class="btn">Add</button>
-    </form>
-  </div>
-
-  <!-- Change own password -->
-  <div class="card">
-    <h2>Change your password</h2>
-    <form method="post">
-      <input type="hidden" name="action" value="change-password">
-      <div class="form-group">
-        <label for="new-password">New password</label>
-        <input type="password" id="new-password" name="password" required minlength="6">
-      </div>
-      <button type="submit" class="btn">Update</button>
-    </form>
-  </div>
-</div>
-
-<!-- User list -->
-<div class="mt-xl">
-  <h2 class="section-title">All users ({{len .Users}})</h2>
-  <div class="list-box">
-    {{range .Users}}
-    <div class="list-row">
-      <span>{{.}}</span>
-      {{if ne . $.User}}
-      <form method="post" onsubmit="return confirm('Remove {{.}}?')">
-        <input type="hidden" name="action" value="remove">
-        <input type="hidden" name="username" value="{{.}}">
-        <button type="submit" class="btn-subtle btn-sm">Remove</button>
-      </form>
-      {{else}}
-      <span class="tag-self">you</span>
-      {{end}}
-    </div>
-    {{end}}
-  </div>
-</div>
-{{template "foot" .}}
diff --git a/thread.go b/thread.go
new file mode 100644
index 0000000..9e9fe97
--- /dev/null
+++ b/thread.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+	"crypto/rand"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"sort"
+	"strings"
+	"sync"
+	"time"
+)
+
+var threadLocks sync.Map
+
+func lockThread(threadPath string) func() {
+	mu := &sync.Mutex{}
+	actual, _ := threadLocks.LoadOrStore(threadPath, mu)
+	mu = actual.(*sync.Mutex)
+	mu.Lock()
+	return func() { mu.Unlock() }
+}
+
+type Thread struct {
+	ID         string  `json:"id"`
+	State      string  `json:"state"` // "open" or "closed"
+	Title      string  `json:"title"`
+	Body       string  `json:"body"`
+	Author     string  `json:"author"`      // AP actor URI or "admin"
+	AuthorName string  `json:"author_name"` // display name
+	Created    string  `json:"created"`
+	Updated    string  `json:"updated"`
+	Replies    []Reply `json:"replies"`
+}
+
+type Reply struct {
+	ID         string `json:"id"`
+	Body       string `json:"body"`
+	Author     string `json:"author"`
+	AuthorName string `json:"author_name"`
+	Created    string `json:"created"`
+}
+
+func threadsDir(repoPath string) string {
+	return filepath.Join(repoPath, ".forge", "threads")
+}
+
+func newULID() string {
+	t := time.Now().UnixMilli()
+	b := make([]byte, 5)
+	rand.Read(b)
+	return fmt.Sprintf("%012x%x", t, b)
+}
+
+func createThread(repoPath, title, body, author, authorName string) (*Thread, error) {
+	dir := threadsDir(repoPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return nil, err
+	}
+
+	now := time.Now().UTC().Format(time.RFC3339)
+	t := &Thread{
+		ID:         newULID(),
+		State:      "open",
+		Title:      title,
+		Body:       body,
+		Author:     author,
+		AuthorName: authorName,
+		Created:    now,
+		Updated:    now,
+		Replies:    []Reply{},
+	}
+
+	return t, writeThread(repoPath, t)
+}
+
+func addReply(repoPath, threadID, body, author, authorName string) error {
+	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
+	unlock := lockThread(threadPath)
+	defer unlock()
+
+	t, err := loadThread(repoPath, threadID)
+	if err != nil {
+		return err
+	}
+
+	now := time.Now().UTC().Format(time.RFC3339)
+	t.Replies = append(t.Replies, Reply{
+		ID:         newULID(),
+		Body:       body,
+		Author:     author,
+		AuthorName: authorName,
+		Created:    now,
+	})
+	t.Updated = now
+
+	return writeThread(repoPath, t)
+}
+
+func closeThread(repoPath, threadID string) error {
+	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
+	unlock := lockThread(threadPath)
+	defer unlock()
+
+	t, err := loadThread(repoPath, threadID)
+	if err != nil {
+		return err
+	}
+	t.State = "closed"
+	t.Updated = time.Now().UTC().Format(time.RFC3339)
+	return writeThread(repoPath, t)
+}
+
+func reopenThread(repoPath, threadID string) error {
+	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
+	unlock := lockThread(threadPath)
+	defer unlock()
+
+	t, err := loadThread(repoPath, threadID)
+	if err != nil {
+		return err
+	}
+	t.State = "open"
+	t.Updated = time.Now().UTC().Format(time.RFC3339)
+	return writeThread(repoPath, t)
+}
+
+func loadThread(repoPath, threadID string) (*Thread, error) {
+	p := filepath.Join(threadsDir(repoPath), threadID+".json")
+	data, err := os.ReadFile(p)
+	if err != nil {
+		return nil, err
+	}
+	var t Thread
+	if err := json.Unmarshal(data, &t); err != nil {
+		return nil, err
+	}
+	return &t, nil
+}
+
+// listThreadsFiltered returns threads matching the given state, plus total open/closed counts.
+// A single pass over the files avoids the double-scan of separate list+count calls.
+func listThreadsFiltered(repoPath, state string) ([]Thread, int, int) {
+	dir := threadsDir(repoPath)
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return nil, 0, 0
+	}
+
+	var threads []Thread
+	openCount, closedCount := 0, 0
+	for _, e := range entries {
+		if !strings.HasSuffix(e.Name(), ".json") {
+			continue
+		}
+		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
+		if err != nil {
+			continue
+		}
+		var t Thread
+		if err := json.Unmarshal(data, &t); err != nil {
+			continue
+		}
+		if t.State == "open" {
+			openCount++
+		} else {
+			closedCount++
+		}
+		if state == "" || t.State == state {
+			threads = append(threads, t)
+		}
+	}
+
+	sort.Slice(threads, func(i, j int) bool {
+		return threads[i].Updated > threads[j].Updated
+	})
+	return threads, openCount, closedCount
+}
+
+func writeThread(repoPath string, t *Thread) error {
+	dir := threadsDir(repoPath)
+	if err := os.MkdirAll(dir, 0755); err != nil {
+		return err
+	}
+	data, err := json.MarshalIndent(t, "", "  ")
+	if err != nil {
+		return err
+	}
+	target := filepath.Join(dir, t.ID+".json")
+	tmp := target + ".tmp"
+	if err := os.WriteFile(tmp, data, 0644); err != nil {
+		return err
+	}
+	return os.Rename(tmp, target)
+}
+
+
diff --git a/thread_handlers.go b/thread_handlers.go
new file mode 100644
index 0000000..2bf96a9
--- /dev/null
+++ b/thread_handlers.go
@@ -0,0 +1,186 @@
+package main
+
+import (
+	"fmt"
+	"html/template"
+	"net/http"
+	"path/filepath"
+	"strings"
+)
+
+func repoPath(cfg Config, repoName string) string {
+	return filepath.Join(cfg.Dir, repoName+".git")
+}
+
+func ThreadsList(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		state := r.URL.Query().Get("state")
+		if state == "" {
+			state = "open"
+		}
+		ref := git.DefaultBranch()
+		rp := repoPath(cfg, git.Name)
+		threads, openCount, closedCount := listThreadsFiltered(rp, state)
+		tmpl.ExecuteTemplate(w, "thread_list.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Threads":     threads,
+			"State":       state,
+			"OpenCount":   openCount,
+			"ClosedCount": closedCount,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "threads",
+		}))
+	}
+}
+
+func ThreadNewGet(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		ref := git.DefaultBranch()
+		tmpl.ExecuteTemplate(w, "thread_new.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "threads",
+		}))
+	}
+}
+
+func ThreadNewPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		title := strings.TrimSpace(r.FormValue("title"))
+		body := strings.TrimSpace(r.FormValue("body"))
+		if title == "" {
+			renderError(w, tmpl, http.StatusBadRequest, "Title is required")
+			return
+		}
+		author, authorName := threadAuthor(r)
+		rp := repoPath(cfg, git.Name)
+		t, err := createThread(rp, title, body, author, authorName)
+		if err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		// Deliver to ActivityPub followers (non-blocking).
+		go deliverThreadCreate(cfg, git.Name, t)
+		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, t.ID), http.StatusSeeOther)
+	}
+}
+
+func ThreadView(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		threadID := r.PathValue("threadId")
+		rp := repoPath(cfg, git.Name)
+		t, err := loadThread(rp, threadID)
+		if err != nil {
+			renderError(w, tmpl, http.StatusNotFound, "Thread not found")
+			return
+		}
+		ref := git.DefaultBranch()
+		tmpl.ExecuteTemplate(w, "thread_view.html", withUser(r, map[string]any{
+			"Repo":        git.Repo(),
+			"IsPrivate":   git.IsPrivate(),
+			"Ref":         ref,
+			"Thread":      t,
+			"Description": git.Description(),
+			"Branches":    git.Branches(),
+			"Tags":        git.Tags(),
+			"Commits":     git.CommitCount(ref),
+			"ActiveTab":   "threads",
+		}))
+	}
+}
+
+func ThreadReplyPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		threadID := r.PathValue("threadId")
+		body := strings.TrimSpace(r.FormValue("body"))
+		if body == "" {
+			http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
+			return
+		}
+		author, authorName := threadAuthor(r)
+		rp := repoPath(cfg, git.Name)
+		if err := addReply(rp, threadID, body, author, authorName); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+		http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", git.Name, threadID), http.StatusSeeOther)
+	}
+}
+
+func threadAuthor(r *http.Request) (author, displayName string) {
+	author = User(r)
+	if author == "" {
+		author = "anonymous"
+	}
+	return author, author
+}
+
+func closeOrReopenWithReply(cfg Config, repoName string, threadID string, r *http.Request, tmpl *template.Template, w http.ResponseWriter, action func(string, string) error) {
+	body := strings.TrimSpace(r.FormValue("body"))
+	rp := repoPath(cfg, repoName)
+
+	// If the user wrote a comment, save it before changing state.
+	if body != "" {
+		author, authorName := threadAuthor(r)
+		if err := addReply(rp, threadID, body, author, authorName); err != nil {
+			renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+			return
+		}
+	}
+
+	if err := action(rp, threadID); err != nil {
+		renderError(w, tmpl, http.StatusInternalServerError, err.Error())
+		return
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s/threads/%s", repoName, threadID), http.StatusSeeOther)
+}
+
+func ThreadClosePost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, closeThread)
+	}
+}
+
+func ThreadReopenPost(cfg Config, tmpl *template.Template) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		git := &Git{Dir: cfg.Dir, Name: r.PathValue("repo")}
+		if !checkRepoAccess(w, r, git) {
+			return
+		}
+		closeOrReopenWithReply(cfg, git.Name, r.PathValue("threadId"), r, tmpl, w, reopenThread)
+	}
+}
diff --git a/threads.go b/threads.go
deleted file mode 100644
index 9e9fe97..0000000
--- a/threads.go
+++ /dev/null
@@ -1,198 +0,0 @@
-package main
-
-import (
-	"crypto/rand"
-	"encoding/json"
-	"fmt"
-	"os"
-	"path/filepath"
-	"sort"
-	"strings"
-	"sync"
-	"time"
-)
-
-var threadLocks sync.Map
-
-func lockThread(threadPath string) func() {
-	mu := &sync.Mutex{}
-	actual, _ := threadLocks.LoadOrStore(threadPath, mu)
-	mu = actual.(*sync.Mutex)
-	mu.Lock()
-	return func() { mu.Unlock() }
-}
-
-type Thread struct {
-	ID         string  `json:"id"`
-	State      string  `json:"state"` // "open" or "closed"
-	Title      string  `json:"title"`
-	Body       string  `json:"body"`
-	Author     string  `json:"author"`      // AP actor URI or "admin"
-	AuthorName string  `json:"author_name"` // display name
-	Created    string  `json:"created"`
-	Updated    string  `json:"updated"`
-	Replies    []Reply `json:"replies"`
-}
-
-type Reply struct {
-	ID         string `json:"id"`
-	Body       string `json:"body"`
-	Author     string `json:"author"`
-	AuthorName string `json:"author_name"`
-	Created    string `json:"created"`
-}
-
-func threadsDir(repoPath string) string {
-	return filepath.Join(repoPath, ".forge", "threads")
-}
-
-func newULID() string {
-	t := time.Now().UnixMilli()
-	b := make([]byte, 5)
-	rand.Read(b)
-	return fmt.Sprintf("%012x%x", t, b)
-}
-
-func createThread(repoPath, title, body, author, authorName string) (*Thread, error) {
-	dir := threadsDir(repoPath)
-	if err := os.MkdirAll(dir, 0755); err != nil {
-		return nil, err
-	}
-
-	now := time.Now().UTC().Format(time.RFC3339)
-	t := &Thread{
-		ID:         newULID(),
-		State:      "open",
-		Title:      title,
-		Body:       body,
-		Author:     author,
-		AuthorName: authorName,
-		Created:    now,
-		Updated:    now,
-		Replies:    []Reply{},
-	}
-
-	return t, writeThread(repoPath, t)
-}
-
-func addReply(repoPath, threadID, body, author, authorName string) error {
-	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
-	unlock := lockThread(threadPath)
-	defer unlock()
-
-	t, err := loadThread(repoPath, threadID)
-	if err != nil {
-		return err
-	}
-
-	now := time.Now().UTC().Format(time.RFC3339)
-	t.Replies = append(t.Replies, Reply{
-		ID:         newULID(),
-		Body:       body,
-		Author:     author,
-		AuthorName: authorName,
-		Created:    now,
-	})
-	t.Updated = now
-
-	return writeThread(repoPath, t)
-}
-
-func closeThread(repoPath, threadID string) error {
-	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
-	unlock := lockThread(threadPath)
-	defer unlock()
-
-	t, err := loadThread(repoPath, threadID)
-	if err != nil {
-		return err
-	}
-	t.State = "closed"
-	t.Updated = time.Now().UTC().Format(time.RFC3339)
-	return writeThread(repoPath, t)
-}
-
-func reopenThread(repoPath, threadID string) error {
-	threadPath := filepath.Join(threadsDir(repoPath), threadID+".json")
-	unlock := lockThread(threadPath)
-	defer unlock()
-
-	t, err := loadThread(repoPath, threadID)
-	if err != nil {
-		return err
-	}
-	t.State = "open"
-	t.Updated = time.Now().UTC().Format(time.RFC3339)
-	return writeThread(repoPath, t)
-}
-
-func loadThread(repoPath, threadID string) (*Thread, error) {
-	p := filepath.Join(threadsDir(repoPath), threadID+".json")
-	data, err := os.ReadFile(p)
-	if err != nil {
-		return nil, err
-	}
-	var t Thread
-	if err := json.Unmarshal(data, &t); err != nil {
-		return nil, err
-	}
-	return &t, nil
-}
-
-// listThreadsFiltered returns threads matching the given state, plus total open/closed counts.
-// A single pass over the files avoids the double-scan of separate list+count calls.
-func listThreadsFiltered(repoPath, state string) ([]Thread, int, int) {
-	dir := threadsDir(repoPath)
-	entries, err := os.ReadDir(dir)
-	if err != nil {
-		return nil, 0, 0
-	}
-
-	var threads []Thread
-	openCount, closedCount := 0, 0
-	for _, e := range entries {
-		if !strings.HasSuffix(e.Name(), ".json") {
-			continue
-		}
-		data, err := os.ReadFile(filepath.Join(dir, e.Name()))
-		if err != nil {
-			continue
-		}
-		var t Thread
-		if err := json.Unmarshal(data, &t); err != nil {
-			continue
-		}
-		if t.State == "open" {
-			openCount++
-		} else {
-			closedCount++
-		}
-		if state == "" || t.State == state {
-			threads = append(threads, t)
-		}
-	}
-
-	sort.Slice(threads, func(i, j int) bool {
-		return threads[i].Updated > threads[j].Updated
-	})
-	return threads, openCount, closedCount
-}
-
-func writeThread(repoPath string, t *Thread) error {
-	dir := threadsDir(repoPath)
-	if err := os.MkdirAll(dir, 0755); err != nil {
-		return err
-	}
-	data, err := json.MarshalIndent(t, "", "  ")
-	if err != nil {
-		return err
-	}
-	target := filepath.Join(dir, t.ID+".json")
-	tmp := target + ".tmp"
-	if err := os.WriteFile(tmp, data, 0644); err != nil {
-		return err
-	}
-	return os.Rename(tmp, target)
-}
-
-