fierj
PublicTiny 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> · <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 — 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> · <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 — 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 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 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)
-}
-
-