Gitfed
bastien-mrq/gitfed / cmd / gitfed-web / handlers_repo_commits.go
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()))
}