feat(webui): add individual tag detail pages

Amolith created

Implement /{repo}/tag/{tag} route displaying tag metadata including
name, authorship, creation date, optional annotation message, and links
to file tree and commit.

Tag dates use tagger date for annotated tags, falling back to commit
author date for lightweight tags, matching the tags list behavior for
consistency.

Implements: a0daef3
Assisted-by: Claude Sonnet 4.5 via Crush

Change summary

pkg/web/templates/tag.html  |  49 +++++++++++++
pkg/web/templates/tags.html |   2 
pkg/web/webui.go            |   4 +
pkg/web/webui_tag.go        | 143 +++++++++++++++++++++++++++++++++++++++
4 files changed, 197 insertions(+), 1 deletion(-)

Detailed changes

pkg/web/templates/tag.html 🔗

@@ -0,0 +1,49 @@
+{{define "content"}}
+<nav aria-label="breadcrumb">
+  <ul>
+    <li><a href="/{{.Repo.Name}}">{{.Repo.Name}}</a></li>
+    <li><a href="/{{.Repo.Name}}/tags">Tags</a></li>
+    <li aria-current="page">{{.Ref.Name.Short}}</li>
+  </ul>
+</nav>
+
+<header>
+  <hgroup>
+    <h2 id="tag-heading">{{.Ref.Name.Short}}</h2>
+    {{if .Commit}}
+    <p>
+      {{ $names := .Commit | attributionNames }}
+      {{ $nlen := len $names }}
+      {{ range $i, $n := $names }}
+        {{ if gt $i 0 }}
+          {{ if eq $nlen 2 }} and {{ else if eq $i (sub $nlen 1) }}, and {{ else }}, {{ end }}
+        {{ end }}
+        <strong>{{$n}}</strong>
+      {{ end }}
+      created <time datetime="{{.TagDate | rfc3339}}" data-tooltip="{{.TagDate | formatDate}}">{{.TagDate | relativeTime}}</time>
+    </p>
+    {{end}}
+  </hgroup>
+  
+  <nav aria-label="Tag actions">
+    <ul>
+      <li><a href="/{{.Repo.Name}}/tree/{{.Ref.Name.Short}}">Browse file tree</a></li>
+      {{if .Commit}}
+      <li><a href="/{{.Repo.Name}}/commit/{{.Commit.ID}}">View commit</a></li>
+      {{end}}
+    </ul>
+  </nav>
+  
+  {{if .Tag}}
+  {{$message := .Tag.Message}}
+  {{if $message}}
+  <pre><code>{{$message}}</code></pre>
+  {{end}}
+  {{else if .Commit}}
+  {{$body := .Commit.Message | commitBody}}
+  {{if $body}}
+  <pre><code>{{$body}}</code></pre>
+  {{end}}
+  {{end}}
+</header>
+{{end}}

pkg/web/templates/tags.html 🔗

@@ -4,7 +4,7 @@
   {{if .Tags}}
   {{range .Tags}}
   <article>
-    <h3>{{.Ref.Name.Short}}</h3>
+    <h3><a href="/{{$.Repo.Name}}/tag/{{.Ref.Name.Short}}">{{.Ref.Name.Short}}</a></h3>
     {{if .TagMessage}}
     <pre><code>{{.TagMessage}}</code></pre>
     {{end}}

pkg/web/webui.go 🔗

@@ -365,6 +365,10 @@ func WebUIController(ctx context.Context, r *mux.Router) {
 	r.Handle(basePrefix+"/tags", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTags)))).
 		Methods(http.MethodGet)
 
+	// Tag detail route
+	r.Handle(basePrefix+"/tag/{tag:.+}", withRepoVars(withWebUIAccess(http.HandlerFunc(repoTag)))).
+		Methods(http.MethodGet)
+
 	// Repository overview (catch-all, must be last)
 	r.Handle(basePrefix, withRepoVars(withWebUIAccess(http.HandlerFunc(repoOverview)))).
 		Methods(http.MethodGet)

pkg/web/webui_tag.go 🔗

@@ -0,0 +1,143 @@
+package web
+
+import (
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/log/v2"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/pkg/config"
+	"github.com/charmbracelet/soft-serve/pkg/proto"
+	"github.com/gorilla/mux"
+)
+
+// TagData contains data for rendering individual tag view.
+type TagData struct {
+	RepoBaseData
+	Ref     *git.Reference
+	Tag     *git.Tag
+	Commit  *git.Commit
+	TagDate time.Time
+}
+
+func repoTag(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+	cfg := config.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	vars := mux.Vars(r)
+	tagName := vars["tag"]
+
+	gr, err := openRepository(repo)
+	if err != nil {
+		logger.Debug("failed to open repository", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	// Check if tag exists
+	if !gr.HasTag(tagName) {
+		logger.Debug("tag not found", "repo", repo.Name(), "tag", tagName)
+		renderNotFound(w, r)
+		return
+	}
+
+	// Get all references and find our tag
+	refs, err := gr.References()
+	if err != nil {
+		logger.Debug("failed to get references", "repo", repo.Name(), "err", err)
+		renderInternalServerError(w, r)
+		return
+	}
+
+	var ref *git.Reference
+	for _, r := range refs {
+		if r.IsTag() && r.Name().Short() == tagName {
+			ref = r
+			break
+		}
+	}
+
+	if ref == nil {
+		logger.Debug("tag reference not found", "repo", repo.Name(), "tag", tagName)
+		renderNotFound(w, r)
+		return
+	}
+
+	var tag *git.Tag
+	var commit *git.Commit
+	var tagDate time.Time
+
+	// Try to get annotated tag
+	tag, err = gr.Tag(tagName)
+	if err == nil && tag != nil {
+		// Annotated tag - get tagger date
+		if tagger := tag.Tagger(); tagger != nil {
+			tagDate = tagger.When
+		}
+		// Get the commit the tag points to via the hash
+		commitHash := ref.ID
+		commit, err = gr.CatFileCommit(commitHash)
+		if err != nil {
+			logger.Debug("failed to get commit from tag", "repo", repo.Name(), "tag", tagName, "err", err)
+			renderInternalServerError(w, r)
+			return
+		}
+	} else {
+		// Lightweight tag - points directly to commit
+		commit, err = gr.CatFileCommit(ref.ID)
+		if err != nil {
+			logger.Debug("failed to get commit", "repo", repo.Name(), "tag", tagName, "err", err)
+			renderNotFound(w, r)
+			return
+		}
+		// Use commit author date for lightweight tags
+		tagDate = commit.Author.When
+	}
+
+	// Fallback to commit date if tag date not available
+	if tagDate.IsZero() && commit != nil {
+		tagDate = commit.Author.When
+	}
+
+	defaultBranch := getDefaultBranch(gr)
+
+	repoDisplayName := repo.ProjectName()
+	if repoDisplayName == "" {
+		repoDisplayName = repo.Name()
+	}
+
+	// Build description from tag message or commit subject
+	description := ""
+	if tag != nil && tag.Message() != "" {
+		description = tag.Message()
+	} else if commit != nil {
+		lines := strings.Split(commit.Message, "\n")
+		if len(lines) > 0 {
+			description = lines[0]
+		}
+	}
+	if len(description) > 200 {
+		description = description[:200] + "..."
+	}
+
+	data := TagData{
+		RepoBaseData: RepoBaseData{
+			BaseData: BaseData{
+				ServerName:  cfg.Name,
+				ActiveTab:   "tags",
+				Title:       tagName + " | " + repoDisplayName,
+				Description: description,
+			},
+			Repo:          repo,
+			DefaultBranch: defaultBranch,
+		},
+		Ref:     ref,
+		Tag:     tag,
+		Commit:  commit,
+		TagDate: tagDate,
+	}
+
+	renderHTML(w, "tag.html", data)
+}