package main
import (
"bytes"
"html/template"
"net/http"
"strconv"
"strings"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/gitexec"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/i18n"
"git.neuromancer.ovh/bastien-mrq/gitfed/internal/store"
)
// commitsPerPage bounds how much `git log` output one page request ever
// buffers — real pagination (?page=) reaches the rest instead of the old
// hard 200-commit cutoff with nothing beyond it.
const commitsPerPage = 50
var repoCommitsTpl = newTpl("repo-commits", `
{{.Flash}}
<div class="gf-crumbs"><a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>{{t .Lang "repo.commits_title"}}</div>
<div class="gf-repo-head">
<h1>{{t .Lang "repo.commits_title"}}</h1>
{{if .Total}}<span class="count">{{t .Lang "repo.stat_commits" .Total}}</span>{{end}}
</div>
<div class="gf-card gf-commit-list">
{{range .Commits}}
<a class="gf-commit-row" href="/repo-commit/{{$.Repo.Name}}?hash={{.Hash}}">
<div class="gf-commit-main">
<div class="subject">{{.Subject}}</div>
<div class="meta">{{.Author}} · {{.Date.Local.Format "2006-01-02 15:04"}}</div>
</div>
<code class="gf-commit-hash">{{.ShortHash}}</code>
</a>
{{else}}
<div class="gf-commit-row"><span class="muted">{{t .Lang "repo.commits_empty"}}</span></div>
{{end}}
</div>
{{if or .HasPrev .HasNext}}
<div class="gf-pager">
{{if .HasPrev}}<a href="/repo-commits/{{.Repo.Name}}?page={{.PrevPage}}" class="gf-btn">← {{t .Lang "repo.pager_prev"}}</a>{{else}}<span class="gf-btn disabled">← {{t .Lang "repo.pager_prev"}}</span>{{end}}
<span class="muted">{{t .Lang "repo.pager_page" .Page}}</span>
{{if .HasNext}}<a href="/repo-commits/{{.Repo.Name}}?page={{.NextPage}}" class="gf-btn">{{t .Lang "repo.pager_next"}} →</a>{{else}}<span class="gf-btn disabled">{{t .Lang "repo.pager_next"}} →</span>{{end}}
</div>
{{end}}
`)
func (s *server) handleRepoCommits(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
lang := s.lang(r)
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
offset := (page - 1) * commitsPerPage
commits, _, err := s.ops.ListCommitsPage(name, commitsPerPage, offset)
if err != nil {
s.serverError(w, r, err)
return
}
// A real page beyond the last one (e.g. a stale bookmark after history
// was rewritten) — same "unknown value 404s" treatment used elsewhere,
// rather than silently rendering an empty list that looks like a repo
// with no commits at all.
if len(commits) == 0 && page > 1 {
http.NotFound(w, r)
return
}
total, _, err := s.ops.CountCommits(name)
if err != nil {
s.serverError(w, r, err)
return
}
var buf bytes.Buffer
_ = repoCommitsTpl.Execute(&buf, struct {
Repo store.Repo
Lang string
Commits []gitexec.Commit
Total, Page, PrevPage, NextPage int
HasPrev, HasNext bool
Flash template.HTML
}{
repo, string(lang), commits, total, page, page - 1, page + 1,
page > 1, offset+len(commits) < total, flash(r),
})
s.render(w, r, name+" — "+i18n.T(lang, "repo.commits_title"), "home", template.HTML(buf.String()))
}
// diffFileView adds display-only fields (badge class, status letter) to a
// gitexec.DiffFile.
type diffFileView struct {
gitexec.DiffFile
Letter string
Class string
}
func newDiffFileView(f gitexec.DiffFile) diffFileView {
letter, class := "M", "plain"
switch f.Status {
case "added":
letter, class = "A", "trusted"
case "deleted":
letter, class = "D", "danger"
case "renamed":
letter, class = "R", "pending"
case "copied":
letter, class = "C", "pending"
}
return diffFileView{DiffFile: f, Letter: letter, Class: class}
}
// diffLine is one line of a unified diff, classified for CSS coloring.
type diffLine struct {
Class string
Text string
}
// diffFileSection groups a diff's lines under the file path they belong to,
// so the detail page can show a small header before each file's hunks.
type diffFileSection struct {
Path string
Lines []diffLine
}
func classifyDiff(diff string) []diffFileSection {
var sections []diffFileSection
var path string
var lines []diffLine
started := false
flush := func() {
if started {
sections = append(sections, diffFileSection{Path: path, Lines: lines})
}
}
for _, l := range strings.Split(diff, "\n") {
if strings.HasPrefix(l, "diff --git ") {
flush()
path = diffFilePath(l)
lines = nil
started = true
}
lines = append(lines, diffLine{Class: classifyDiffLine(l), Text: l})
}
flush()
return sections
}
func diffFilePath(header string) string {
header = strings.TrimPrefix(header, "diff --git ")
if idx := strings.Index(header, " b/"); idx >= 0 {
return strings.TrimPrefix(header[:idx], "a/")
}
return header
}
func classifyDiffLine(l string) string {
switch {
case strings.HasPrefix(l, "diff --git"), strings.HasPrefix(l, "index "),
strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "+++ "),
strings.HasPrefix(l, "new file mode"), strings.HasPrefix(l, "deleted file mode"),
strings.HasPrefix(l, "similarity index"), strings.HasPrefix(l, "rename from"),
strings.HasPrefix(l, "rename to"), strings.HasPrefix(l, "old mode"),
strings.HasPrefix(l, "new mode"), strings.HasPrefix(l, "Binary files"):
return "meta"
case strings.HasPrefix(l, "@@"):
return "hunk"
case strings.HasPrefix(l, "+") && !strings.HasPrefix(l, "+++"):
return "add"
case strings.HasPrefix(l, "-") && !strings.HasPrefix(l, "---"):
return "del"
default:
return "ctx"
}
}
var repoCommitTpl = newTpl("repo-commit", `
{{.Flash}}
<div class="gf-crumbs">
<a href="/r/{{.Repo.Name}}">{{.Repo.Name}}</a><span class="sep">/</span>
<a href="/repo-commits/{{.Repo.Name}}">{{t .Lang "repo.commits_title"}}</a><span class="sep">/</span>
<code>{{.Detail.ShortHash}}</code>
</div>
<div class="gf-card">
<div class="gf-commit-detail-head">
<p class="subject">{{.Detail.Subject}}</p>
{{if .Detail.Body}}<p class="body">{{.Detail.Body}}</p>{{end}}
<div class="meta-row">
<span class="field">{{.Detail.Author}}</span>
<span class="field">{{.Detail.Date.Local.Format "2006-01-02 15:04"}}</span>
<span class="field">{{t .Lang "repo.commit_label"}} <code class="hash">{{.Detail.Hash}}</code></span>
{{range .Detail.Parents}}<span class="field">{{t $.Lang "repo.parent_label"}} <a href="/repo-commit/{{$.Repo.Name}}?hash={{.}}"><code class="hash">{{.}}</code></a></span>{{end}}
</div>
</div>
<div class="gf-diffstat-head">
<span>{{len .Detail.Files}} {{t .Lang "repo.files_changed"}}</span>
<span class="totals"><span class="add">+{{.TotalAdd}}</span> <span class="del">−{{.TotalDel}}</span></span>
</div>
<div>
{{range .Files}}
<div class="gf-diffstat-row">
<span class="badge {{.Class}}">{{.Letter}}</span>
<span class="path">{{.Path}}</span>
{{if .Binary}}<span class="stat muted">{{t $.Lang "repo.binary_file"}}</span>{{else}}<span class="stat"><span class="add">+{{.Insertions}}</span> <span class="del">−{{.Deletions}}</span></span>{{end}}
</div>
{{end}}
</div>
</div>
{{if .DiffSections}}
<div class="gf-card">
{{range .DiffSections}}
<div class="gf-diff-file">
<div class="gf-diff-file-head">{{.Path}}</div>
<div class="gf-diff">{{range .Lines}}<span class="line {{.Class}}">{{.Text}}</span>
{{end}}</div>
</div>
{{end}}
</div>
{{end}}
{{if .Truncated}}<p class="muted" style="text-align:center;">{{t .Lang "repo.diff_truncated"}}</p>{{end}}
`)
func (s *server) handleRepoCommitDetail(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("repo")
hash := strings.TrimSpace(r.URL.Query().Get("hash"))
lang := s.lang(r)
if hash == "" {
http.NotFound(w, r)
return
}
repo, err := s.ops.GetRepo(name)
if err != nil || !s.canView(r, repo) {
http.NotFound(w, r)
return
}
detail, found, err := s.ops.ShowCommit(name, hash)
if err != nil {
s.serverError(w, r, err)
return
}
if !found {
http.NotFound(w, r)
return
}
diff, truncated, err := s.ops.CommitDiff(name, hash)
if err != nil {
s.serverError(w, r, err)
return
}
files := make([]diffFileView, len(detail.Files))
var totalAdd, totalDel int
for i, f := range detail.Files {
files[i] = newDiffFileView(f)
totalAdd += f.Insertions
totalDel += f.Deletions
}
var buf bytes.Buffer
_ = repoCommitTpl.Execute(&buf, struct {
Repo store.Repo
Lang string
Detail gitexec.CommitDetail
Files []diffFileView
TotalAdd int
TotalDel int
DiffSections []diffFileSection
Truncated bool
Flash template.HTML
}{repo, string(lang), detail, files, totalAdd, totalDel, classifyDiff(diff), truncated, flash(r)})
s.render(w, r, detail.ShortHash+" — "+name, "home", template.HTML(buf.String()))
}