New Sign in

fierj

Public

Tiny personal git forge

eec8b3c5e34928aa9a9719f9d12e5d2cd85f0fc6
diff --git a/activitypub.go b/activitypub.go
new file mode 100644
index 0000000..1e905b6
--- /dev/null
+++ b/activitypub.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/handlers.go b/handlers.go
deleted file mode 100644
index e3e5f51..0000000
--- a/handlers.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_repo.go b/handlers_repo.go
new file mode 100644
index 0000000..e3e5f51
--- /dev/null
+++ b/handlers_repo.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, "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
new file mode 100644
index 0000000..b9ea03f
--- /dev/null
+++ b/handlers_threads.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, "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/main.go b/main.go
index 62393c2..098f36e 100644
--- a/main.go
+++ b/main.go
@@ -67,6 +67,14 @@ func main() {
 	mux.HandleFunc("GET /{repo}/settings", SettingsGet(cfg, tmpl))
 	mux.HandleFunc("POST /{repo}/settings", SettingsPost(cfg, tmpl))
 
+	mux.HandleFunc("GET /{repo}/threads", ThreadsList(cfg, tmpl))
+	mux.HandleFunc("GET /{repo}/threads/new", ThreadNewGet(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/threads/new", ThreadNewPost(cfg, tmpl))
+	mux.HandleFunc("GET /{repo}/threads/{threadId}", ThreadView(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/threads/{threadId}/reply", ThreadReplyPost(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/threads/{threadId}/close", ThreadClosePost(cfg, tmpl))
+	mux.HandleFunc("POST /{repo}/threads/{threadId}/reopen", ThreadReopenPost(cfg, tmpl))
+
 	// Wrap with auth middleware and setup redirect.
 	var handler http.Handler = mux
 	if len(cookieSecret) > 0 {
diff --git a/templates/thread_new.html b/templates/thread_new.html
new file mode 100644
index 0000000..ac0b93b
--- /dev/null
+++ b/templates/thread_new.html
@@ -0,0 +1,21 @@
+{{template "head" .}}
+{{define "title"}}new thread - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="thread-new-form">
+    <h2>New Thread</h2>
+    <form method="POST" action="/{{.Repo}}/threads/new">
+        <div class="form-group">
+            <label for="title">Title</label>
+            <input type="text" id="title" name="title" placeholder="Brief summary" required>
+        </div>
+        <div class="form-group">
+            <label for="body">Description</label>
+            <textarea id="body" name="body" rows="8" placeholder="Detailed description (markdown supported)"></textarea>
+        </div>
+        <button type="submit" class="btn btn-primary">Create thread</button>
+    </form>
+</div>
+{{template "foot" .}}
+
diff --git a/templates/thread_view.html b/templates/thread_view.html
new file mode 100644
index 0000000..e5d6969
--- /dev/null
+++ b/templates/thread_view.html
@@ -0,0 +1,56 @@
+{{template "head" .}}
+{{define "title"}}{{.Thread.Title}} - {{.Repo}} - fierj{{end}}
+{{template "repo-header" .}}
+{{template "repo-tabs" .}}
+
+<div class="thread-detail">
+    <div class="thread-detail-header">
+        <h2>{{.Thread.Title}}</h2>
+        <span class="thread-state thread-state-{{.Thread.State}}">{{.Thread.State}}</span>
+    </div>
+    <p class="thread-meta-line">
+        <strong>{{.Thread.AuthorName}}</strong> opened this thread · {{timeAgo .Thread.Created}}
+    </p>
+</div>
+
+<!-- Original post -->
+<div class="thread-comment">
+    <div class="thread-comment-header">
+        <strong>{{.Thread.AuthorName}}</strong>
+        <span>{{timeAgo .Thread.Created}}</span>
+    </div>
+    <div class="thread-comment-body markdown-body">{{.Thread.Body}}</div>
+</div>
+
+<!-- Replies -->
+{{range .Thread.Replies}}
+<div class="thread-comment">
+    <div class="thread-comment-header">
+        <strong>{{.AuthorName}}</strong>
+        <span>{{timeAgo .Created}}</span>
+    </div>
+    <div class="thread-comment-body markdown-body">{{.Body}}</div>
+</div>
+{{end}}
+
+<!-- Reply form -->
+<div class="thread-reply-form">
+    <form method="POST" action="/{{.Repo}}/threads/{{.Thread.ID}}/reply">
+        <div class="form-group">
+            <textarea name="body" rows="4" placeholder="Leave a comment..."></textarea>
+        </div>
+        <div class="thread-actions">
+            {{if eq .Thread.State "open"}}
+            <button type="submit" class="btn btn-primary">Comment</button>
+            <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/close" class="btn btn-secondary">Close
+                thread</button>
+            {{else}}
+            <button type="submit" class="btn btn-primary">Comment</button>
+            <button type="submit" formaction="/{{.Repo}}/threads/{{.Thread.ID}}/reopen" class="btn btn-secondary">Reopen
+                thread</button>
+            {{end}}
+        </div>
+    </form>
+</div>
+{{template "foot" .}}
+
diff --git a/templates/threads.html b/templates/threads.html
new file mode 100644
index 0000000..3b52c9c
--- /dev/null
+++ b/templates/threads.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/threads.go b/threads.go
new file mode 100644
index 0000000..9e9fe97
--- /dev/null
+++ b/threads.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)
+}
+
+