feat: specify custom readme file

Ayman Bagabas created

* Optionally specify per repo readme file path in config
* Default to "README*" glob and choose the first glob match
* Refactor code and move stuff around

Fixes: https://github.com/charmbracelet/soft-serve/issues/31

Change summary

go.mod                                      |  1 
go.sum                                      |  2 
internal/config/config.go                   | 55 +++++++++++++++++++-
internal/config/defaults.go                 |  4 
internal/git/git.go                         | 60 +++++++++++++++-------
internal/tui/bubbles/git/about/bubble.go    | 14 ++++
internal/tui/bubbles/git/tree/bubble.go     | 31 +---------
internal/tui/bubbles/git/types/formatter.go | 43 +++++++++++++---
internal/tui/bubbles/git/types/git.go       |  1 
internal/tui/commands.go                    | 22 --------
10 files changed, 151 insertions(+), 82 deletions(-)

Detailed changes

go.mod 🔗

@@ -23,6 +23,7 @@ require (
 )
 
 require (
+	github.com/gobwas/glob v0.2.3
 	github.com/muesli/mango v0.1.0
 	github.com/muesli/roff v0.1.0
 )

go.sum 🔗

@@ -61,6 +61,8 @@ github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2Su
 github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0=
 github.com/go-git/go-git/v5 v5.4.2 h1:BXyZu9t0VkbiHtqrsvdq39UDhGJTl1h55VW6CSC4aY4=
 github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc=
+github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
+github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
 github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=

internal/config/config.go 🔗

@@ -1,10 +1,10 @@
 package config
 
 import (
-	"os/exec"
-	"path/filepath"
+	"bytes"
 	"strings"
 	"sync"
+	"text/template"
 
 	"golang.org/x/crypto/ssh"
 	"gopkg.in/yaml.v2"
@@ -46,6 +46,7 @@ type Repo struct {
 	Repo    string `yaml:"repo"`
 	Note    string `yaml:"note"`
 	Private bool   `yaml:"private"`
+	Readme  string `yaml:"readme"`
 }
 
 // NewConfig creates a new internal Config struct.
@@ -127,6 +128,41 @@ func (cfg *Config) Reload() error {
 	if err != nil {
 		return fmt.Errorf("bad yaml in config.yaml: %s", err)
 	}
+	for _, r := range cfg.Source.AllRepos() {
+		name := r.Name()
+		pat := "README*"
+		rp := ""
+		for _, rr := range cfg.Repos {
+			if name == rr.Repo {
+				rp = rr.Readme
+				break
+			}
+		}
+		if rp != "" {
+			pat = rp
+		}
+		rm := ""
+		f, err := r.FindLatestFile(pat)
+		if err != nil && err != object.ErrFileNotFound {
+			return err
+		}
+		if err == nil {
+			fc, err := f.Contents()
+			if err != nil {
+				return err
+			}
+			rm = fc
+			r.ReadmePath = f.Name
+		}
+		if name == "config" {
+			md, err := templatize(rm, cfg)
+			if err != nil {
+				return err
+			}
+			rm = md
+		}
+		r.Readme = rm
+	}
 	return nil
 }
 
@@ -150,7 +186,7 @@ func (cfg *Config) createDefaultConfigRepo(yaml string) error {
 	if err != nil {
 		return err
 	}
-	_, err = rs.GetRepo(cn)
+	r, err := rs.GetRepo(cn)
 	if err == git.ErrMissingRepo {
 		cr, err := rs.InitRepo(cn, true)
 		if err != nil {
@@ -218,3 +254,16 @@ func (cfg *Config) isPrivate(repo string) bool {
 	}
 	return false
 }
+
+func templatize(mdt string, tmpl interface{}) (string, error) {
+	t, err := template.New("readme").Parse(mdt)
+	if err != nil {
+		return "", err
+	}
+	buf := &bytes.Buffer{}
+	err = t.Execute(buf, tmpl)
+	if err != nil {
+		return "", err
+	}
+	return buf.String(), nil
+}

internal/config/defaults.go 🔗

@@ -19,13 +19,13 @@ anon-access: %s
 # will be accepted.
 allow-keyless: false
 
-# Customize repo display in the menu. Only repos in this list will appear in
-# the TUI.
+# Customize repo display in the menu.
 repos:
   - name: Home
     repo: config
     private: true
     note: "Configuration and content repo for this server"
+    readme: README.md
 `
 
 const hasKeyUserConfig = `

internal/git/git.go 🔗

@@ -14,8 +14,10 @@ import (
 	"github.com/go-git/go-git/v5"
 	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/go-git/go-git/v5/plumbing/storer"
 	"github.com/go-git/go-git/v5/plumbing/transport"
 	"github.com/go-git/go-git/v5/storage/memory"
+	"github.com/gobwas/glob"
 )
 
 // ErrMissingRepo indicates that the requested repository could not be found.
@@ -26,6 +28,7 @@ type Repo struct {
 	path       string
 	repository *git.Repository
 	Readme     string
+	ReadmePath string
 	refCommits map[plumbing.Hash]gitypes.Commits
 	head       *plumbing.Reference
 	refs       []*plumbing.Reference
@@ -107,6 +110,7 @@ func (r *Repo) commitForHash(hash plumbing.Hash) (*object.Commit, error) {
 	return co, nil
 }
 
+// PatchCtx returns the patch for a given commit.
 func (r *Repo) PatchCtx(ctx context.Context, commit *object.Commit) (*object.Patch, error) {
 	hash := commit.Hash
 	p, ok := r.patch[hash]
@@ -199,14 +203,12 @@ func (r *Repo) targetHash(ref *plumbing.Reference) (plumbing.Hash, error) {
 
 // GetReadme returns the readme for a repository.
 func (r *Repo) GetReadme() string {
-	if r.Readme != "" {
-		return r.Readme
-	}
-	md, err := r.LatestFile("README.md")
-	if err != nil {
-		return ""
-	}
-	return md
+	return r.Readme
+}
+
+// GetReadmePath returns the path to the readme for a repository.
+func (r *Repo) GetReadmePath() string {
+	return r.ReadmePath
 }
 
 // RepoSource is a reference to an on-disk repositories.
@@ -310,13 +312,6 @@ func (rs *RepoSource) loadRepo(path string, rg *git.Repository) (*Repo, error) {
 		return nil, err
 	}
 	r.head = ref
-	rm, err := r.LatestFile("README.md")
-	if err == object.ErrFileNotFound {
-		rm = ""
-	} else if err != nil {
-		return nil, err
-	}
-	r.Readme = rm
 	l, err := r.repository.Log(&git.LogOptions{All: true})
 	if err != nil {
 		return nil, err
@@ -341,13 +336,40 @@ func (rs *RepoSource) loadRepo(path string, rg *git.Repository) (*Repo, error) {
 	return r, nil
 }
 
-// LatestFile returns the latest file at the specified path in the repository.
-func (r *Repo) LatestFile(path string) (string, error) {
+// FindLatestFile returns the latest file for a given path.
+func (r *Repo) FindLatestFile(pattern string) (*object.File, error) {
+	g, err := glob.Compile(pattern)
+	if err != nil {
+		return nil, err
+	}
 	c, err := r.commitForHash(r.head.Hash())
 	if err != nil {
-		return "", err
+		return nil, err
 	}
-	f, err := c.File(path)
+	fi, err := c.Files()
+	if err != nil {
+		return nil, err
+	}
+	var f *object.File
+	err = fi.ForEach(func(ff *object.File) error {
+		if g.Match(ff.Name) {
+			f = ff
+			return storer.ErrStop
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	if f == nil {
+		return nil, object.ErrFileNotFound
+	}
+	return f, nil
+}
+
+// LatestFile returns the contents of the latest file at the specified path in the repository.
+func (r *Repo) LatestFile(pattern string) (string, error) {
+	f, err := r.FindLatestFile(pattern)
 	if err != nil {
 		return "", err
 	}

internal/tui/bubbles/git/about/bubble.go 🔗

@@ -8,6 +8,7 @@ import (
 	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/muesli/reflow/wrap"
 )
 
 type Bubble struct {
@@ -92,7 +93,18 @@ func (b *Bubble) glamourize() (string, error) {
 	if rm == "" {
 		return b.styles.AboutNoReadme.Render("No readme found."), nil
 	}
-	return types.Glamourize(w, rm)
+	f, err := types.RenderFile(b.repo.GetReadmePath(), rm, w)
+	if err != nil {
+		return "", err
+	}
+	// For now, hard-wrap long lines in Glamour that would otherwise break the
+	// layout when wrapping. This may be due to #43 in Reflow, which has to do
+	// with a bug in the way lines longer than the given width are wrapped.
+	//
+	//     https://github.com/muesli/reflow/issues/43
+	//
+	// TODO: solve this upstream in Glamour/Reflow.
+	return wrap.String(f, w), nil
 }
 
 func (b *Bubble) setupCmd() tea.Msg {

internal/tui/bubbles/git/tree/bubble.go 🔗

@@ -7,11 +7,9 @@ import (
 	"sort"
 	"strings"
 
-	"github.com/alecthomas/chroma/lexers"
 	"github.com/charmbracelet/bubbles/list"
 	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
-	gansi "github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
@@ -336,31 +334,12 @@ func (b *Bubble) renderFile(m fileMsg) string {
 	if len(strings.Split(c, "\n")) > types.MaxDiffLines {
 		s.WriteString(types.ErrFileTooLarge.Error())
 	} else {
-		lexer := lexers.Match(b.path)
-		lang := ""
-		if lexer != nil && lexer.Config() != nil {
-			lang = lexer.Config().Name
-		}
-		formatter := &gansi.CodeBlockElement{
-			Code:     c,
-			Language: lang,
-		}
-		if lang == "markdown" {
-			w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize()
-			md, err := types.Glamourize(w, c)
-			if err != nil {
-				s.WriteString(err.Error())
-			} else {
-				s.WriteString(md)
-			}
+		w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize()
+		f, err := types.RenderFile(b.path, m.content, w)
+		if err != nil {
+			s.WriteString(err.Error())
 		} else {
-			r := strings.Builder{}
-			err := formatter.Render(&r, types.RenderCtx)
-			if err != nil {
-				s.WriteString(err.Error())
-			} else {
-				s.WriteString(r.String())
-			}
+			s.WriteString(f)
 		}
 	}
 	return b.style.TreeFileContent.Copy().Width(b.width - b.widthMargin).Render(s.String())

internal/tui/bubbles/git/types/formatter.go 🔗

@@ -1,9 +1,11 @@
 package types
 
 import (
+	"strings"
+
+	"github.com/alecthomas/chroma/lexers"
 	"github.com/charmbracelet/glamour"
 	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/muesli/reflow/wrap"
 	"github.com/muesli/termenv"
 )
 
@@ -52,12 +54,35 @@ func Glamourize(w int, md string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	// For now, hard-wrap long lines in Glamour that would otherwise break the
-	// layout when wrapping. This may be due to #43 in Reflow, which has to do
-	// with a bug in the way lines longer than the given width are wrapped.
-	//
-	//     https://github.com/muesli/reflow/issues/43
-	//
-	// TODO: solve this upstream in Glamour/Reflow.
-	return wrap.String(mdt, w), nil
+	return mdt, nil
+}
+
+func RenderFile(path, content string, width int) (string, error) {
+	lexer := lexers.Fallback
+	if path == "" {
+		lexer = lexers.Analyse(content)
+	} else {
+		lexer = lexers.Match(path)
+	}
+	lang := ""
+	if lexer != nil && lexer.Config() != nil {
+		lang = lexer.Config().Name
+	}
+	formatter := &gansi.CodeBlockElement{
+		Code:     content,
+		Language: lang,
+	}
+	if lang == "markdown" {
+		md, err := Glamourize(width, content)
+		if err != nil {
+			return "", err
+		}
+		return md, nil
+	}
+	r := strings.Builder{}
+	err := formatter.Render(&r, RenderCtx)
+	if err != nil {
+		return "", err
+	}
+	return r.String(), nil
 }

internal/tui/bubbles/git/types/git.go 🔗

@@ -14,6 +14,7 @@ type Repo interface {
 	SetHEAD(*plumbing.Reference) error
 	GetReferences() []*plumbing.Reference
 	GetReadme() string
+	GetReadmePath() string
 	GetCommits(*plumbing.Reference) (Commits, error)
 	Repository() *git.Repository
 	Tree(*plumbing.Reference, string) (*object.Tree, error)

internal/tui/commands.go 🔗

@@ -1,9 +1,7 @@
 package tui
 
 import (
-	"bytes"
 	"fmt"
-	"text/template"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
@@ -102,13 +100,6 @@ func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
 	if err != nil {
 		return me, err
 	}
-	if rn == "config" {
-		md, err := templatize(r.Readme, b.config)
-		if err != nil {
-			return me, err
-		}
-		r.Readme = md
-	}
 	boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize()
 	// TODO: also send this along with a tea.WindowSizeMsg
 	var heightMargin = lipgloss.Height(b.headerView()) +
@@ -125,16 +116,3 @@ func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
 	me.bubble = rb
 	return me, nil
 }
-
-func templatize(mdt string, tmpl interface{}) (string, error) {
-	t, err := template.New("readme").Parse(mdt)
-	if err != nil {
-		return "", err
-	}
-	buf := &bytes.Buffer{}
-	err = t.Execute(buf, tmpl)
-	if err != nil {
-		return "", err
-	}
-	return buf.String(), nil
-}