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
)
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
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(-)
@@ -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
)
@@ -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=
@@ -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
+}
@@ -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 = `
@@ -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
}
@@ -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 {
@@ -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())
@@ -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
}
@@ -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)
@@ -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
-}