diff --git a/go.mod b/go.mod old mode 100644 new mode 100755 index cf1b09a59d8632c429f7c463253b5f089fdeebf6..707bafd46d611de2489bb2f0ca43985bcd68ccf4 --- a/go.mod +++ b/go.mod @@ -12,18 +12,20 @@ require ( github.com/charmbracelet/wish v0.2.1-0.20220208182816-534842b53d2a github.com/dustin/go-humanize v1.0.0 github.com/gliderlabs/ssh v0.3.3 - github.com/go-git/go-billy/v5 v5.3.1 - github.com/go-git/go-git/v5 v5.4.2 + github.com/go-git/go-billy/v5 v5.3.1 // indirect + github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 // indirect github.com/matryer/is v1.2.0 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.11.0 - github.com/sergi/go-diff v1.1.0 // indirect + github.com/sergi/go-diff v1.1.0 golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce gopkg.in/yaml.v2 v2.4.0 ) require ( github.com/gobwas/glob v0.2.3 + github.com/gogs/git-module v1.5.0 + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da github.com/muesli/mango v0.1.0 github.com/muesli/roff v0.1.0 ) @@ -40,7 +42,6 @@ require ( github.com/dlclark/regexp2 v1.4.0 // indirect github.com/emirpasic/gods v1.12.0 // indirect github.com/go-git/gcfg v1.5.0 // indirect - github.com/google/go-cmp v0.5.6 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect @@ -48,6 +49,7 @@ require ( github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 // indirect github.com/microcosm-cc/bluemonday v1.0.17 // indirect github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect @@ -58,10 +60,8 @@ require ( github.com/xanzy/ssh-agent v0.3.1 // indirect github.com/yuin/goldmark v1.4.4 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect - golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect + golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect - golang.org/x/text v0.3.7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect ) diff --git a/go.sum b/go.sum index 28b4b112f5b43f968ef2cc8e825ee12b8cdc241b..9607f28d3fbd3526e1af37d2f18e85dfd95de631 100644 --- a/go.sum +++ b/go.sum @@ -59,13 +59,17 @@ github.com/go-git/go-billy/v5 v5.3.1 h1:CPiOUAzKtMRvolEKw+bG1PLRpT7D3LIs3/3ey4Ai github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= github.com/go-git/go-git-fixtures/v4 v4.2.1 h1:n9gGL1Ct/yIw+nfsfr8s4+sbhT+Ncu2SubfXjIWgci8= 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/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 h1:1RSUwVK7VjTeA82kcLIqz1EU70QRwFdZUlJW58gP4GY= +github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4/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/gogs/git-module v1.5.0 h1:2aAO79c36R3L6TdKutbVJwr0YwSWfRbPNP456yxDXtk= +github.com/gogs/git-module v1.5.0/go.mod h1:oN37FFStFjdnTJXsSbhIHKJXh2YeDsEcXPATVz/oeuQ= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= 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= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= @@ -98,6 +102,8 @@ github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRC github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX7QIQiEwLdRVr3RoMG+i0SbBO1Qu+7yVk= +github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo= github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= @@ -160,8 +166,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220111093109-d55c255bac03 h1:0FB83qp0AzVJm+0wcIlauAjJ+tNdh7jLuacRYCIVv7s= -golang.org/x/net v0.0.0-20220111093109-d55c255bac03/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e h1:vcxGaoTs7kV8m5Np9uUNQin4BrLOthgV7252N8V+FwY= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -178,6 +186,7 @@ golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY= golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -189,7 +198,6 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -202,6 +210,5 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go index 10c9ed3607f220c601d8f585c5df1aeff94733d2..b779a7344f0d3a883d5623b37da71f8dbb298b1b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,7 @@ package config import ( "bytes" "log" + "path/filepath" "strings" "sync" "text/template" @@ -15,8 +16,7 @@ import ( "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/internal/git" - gg "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing/object" + gg "github.com/gogs/git-module" ) // Config is the Soft Serve configuration. @@ -30,7 +30,7 @@ type Config struct { Repos []Repo `yaml:"repos"` Source *git.RepoSource Cfg *config.Config - reloadMtx sync.Mutex + mtx sync.Mutex } // User contains user-level configuration for a repository. @@ -106,13 +106,17 @@ func NewConfig(cfg *config.Config) (*Config, error) { if err != nil { return nil, err } + err = c.Reload() + if err != nil { + return nil, err + } return c, nil } // Reload reloads the configuration. func (cfg *Config) Reload() error { - cfg.reloadMtx.Lock() - defer cfg.reloadMtx.Unlock() + cfg.mtx.Lock() + defer cfg.mtx.Unlock() err := cfg.Source.LoadRepos() if err != nil { return err @@ -121,7 +125,7 @@ func (cfg *Config) Reload() error { if err != nil { return err } - cs, err := cr.LatestFile("config.yaml") + cs, _, err := cr.LatestFile("config.yaml") if err != nil { return err } @@ -147,18 +151,8 @@ func (cfg *Config) Reload() error { 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 - } + fc, fp, _ := r.LatestFile(pat) + rm = fc if name == "config" { md, err := templatize(rm, cfg) if err != nil { @@ -166,7 +160,7 @@ func (cfg *Config) Reload() error { } rm = md } - r.Readme = rm + r.SetReadme(rm, fp) } return nil } @@ -187,21 +181,15 @@ func createFile(path string, content string) error { func (cfg *Config) createDefaultConfigRepo(yaml string) error { cn := "config" rs := cfg.Source - err := rs.LoadRepos() - if err != nil { - return err - } - _, err = rs.GetRepo(cn) - if err == git.ErrMissingRepo { - cr, err := rs.InitRepo(cn, true) - if err != nil { - return err - } - wt, err := cr.Repository().Worktree() + err := rs.LoadRepo(cn) + if os.IsNotExist(err) { + repo, err := rs.InitRepo(cn, true) if err != nil { return err } - rm, err := wt.Filesystem.Create("README.md") + wt := repo.Path() + defer os.RemoveAll(wt) + rm, err := os.Create(filepath.Join(wt, "README.md")) if err != nil { return err } @@ -209,7 +197,7 @@ func (cfg *Config) createDefaultConfigRepo(yaml string) error { if err != nil { return err } - cf, err := wt.Filesystem.Create("config.yaml") + cf, err := os.Create(filepath.Join(wt, "config.yaml")) if err != nil { return err } @@ -217,32 +205,25 @@ func (cfg *Config) createDefaultConfigRepo(yaml string) error { if err != nil { return err } - _, err = wt.Add("README.md") + err = gg.Add(wt, gg.AddOptions{All: true}) if err != nil { return err } - _, err = wt.Add("config.yaml") + err = gg.CreateCommit(wt, &gg.Signature{ + Name: "Soft Serve Server", + Email: "vt100@charm.sh", + }, "Default init") if err != nil { return err } - _, err = wt.Commit("Default init", &gg.CommitOptions{ - All: true, - Author: &object.Signature{ - Name: "Soft Serve Server", - Email: "vt100@charm.sh", - }, - }) - if err != nil { - return err - } - err = cr.Repository().Push(&gg.PushOptions{}) + err = repo.Push("origin", "master") if err != nil { return err } } else if err != nil { return err } - return cfg.Reload() + return nil } func (cfg *Config) isPrivate(repo string) bool { diff --git a/internal/git/git.go b/internal/git/git.go index 1762d57d7dec95520b44d1871c4c611c7dd56173..87c84ac9f91d39ec939ae62c248a2e7ac2b79bc1 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -1,24 +1,15 @@ package git import ( - "context" "errors" "log" "os" - "os/exec" "path/filepath" - "sort" "sync" - gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" - "github.com/go-git/go-billy/v5/memfs" - "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/charmbracelet/soft-serve/pkg/git" "github.com/gobwas/glob" + "github.com/golang/groupcache/lru" ) // ErrMissingRepo indicates that the requested repository could not be found. @@ -28,195 +19,126 @@ var ErrMissingRepo = errors.New("missing repo") type Repo struct { path string repository *git.Repository - Readme string - ReadmePath string - refCommits map[plumbing.Hash]gitypes.Commits - head *plumbing.Reference - refs []*plumbing.Reference - trees map[plumbing.Hash]*object.Tree - commits map[plumbing.Hash]*object.Commit - patch map[plumbing.Hash]*object.Patch + readme string + readmePath string + head *git.Reference + refs []*git.Reference + patchCache *lru.Cache } -// GetName returns the name of the repository. -func (r *Repo) Name() string { - return filepath.Base(r.path) -} - -// GetHEAD returns the reference for a repository. -func (r *Repo) GetHEAD() *plumbing.Reference { - return r.head -} - -// SetHEAD sets the repository head reference. -func (r *Repo) SetHEAD(ref *plumbing.Reference) error { - r.head = ref - return nil -} - -// GetReferences returns the references for a repository. -func (r *Repo) GetReferences() []*plumbing.Reference { - return r.refs -} - -// GetRepository returns the underlying go-git repository object. -func (r *Repo) Repository() *git.Repository { - return r.repository -} - -// Tree returns the git tree for a given path. -func (r *Repo) Tree(ref *plumbing.Reference, path string) (*object.Tree, error) { - path = filepath.Clean(path) - hash, err := r.targetHash(ref) +// Open opens a Git repository. +func (rs *RepoSource) Open(path string) (*Repo, error) { + rg, err := git.Open(path) if err != nil { return nil, err } - c, err := r.commitForHash(hash) + r := &Repo{ + path: path, + repository: rg, + patchCache: lru.New(1000), + } + _, err = r.HEAD() if err != nil { return nil, err } - t, err := r.treeForHash(c.TreeHash) + _, err = r.References() if err != nil { return nil, err } - if path == "." { - return t, nil - } - return t.Tree(path) + return r, nil } -func (r *Repo) treeForHash(treeHash plumbing.Hash) (*object.Tree, error) { - var err error - t, ok := r.trees[treeHash] - if !ok { - t, err = r.repository.TreeObject(treeHash) - if err != nil { - return nil, err - } - r.trees[treeHash] = t - } - return t, nil +// Path returns the path to the repository. +func (r *Repo) Path() string { + return r.path } -func (r *Repo) commitForHash(hash plumbing.Hash) (*object.Commit, error) { - var err error - co, ok := r.commits[hash] - if !ok { - co, err = r.repository.CommitObject(hash) - if err != nil { - return nil, err - } - r.commits[hash] = co - } - return co, nil +// GetName returns the name of the repository. +func (r *Repo) Name() string { + return filepath.Base(r.path) } -// 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] - if !ok { - c, err := r.commitForHash(hash) - if err != nil { - return nil, err - } - // Using commit trees fixes the issue when generating diff for the first commit - // https://github.com/go-git/go-git/issues/281 - tree, err := r.treeForHash(c.TreeHash) - if err != nil { - return nil, err - } - var parent *object.Commit - parentTree := &object.Tree{} - if c.NumParents() > 0 { - parent, err = r.commitForHash(c.ParentHashes[0]) - if err != nil { - return nil, err - } - parentTree, err = r.treeForHash(parent.TreeHash) - if err != nil { - return nil, err - } - } - p, err = parentTree.PatchContext(ctx, tree) - if err != nil { - return nil, err - } - } - return p, nil +// Readme returns the readme and its path for the repository. +func (r *Repo) Readme() (readme string, path string) { + return r.readme, r.readmePath +} + +// SetReadme sets the readme for the repository. +func (r *Repo) SetReadme(readme, path string) { + r.readme = readme + r.readmePath = path } -// GetCommits returns the commits for a repository. -func (r *Repo) GetCommits(ref *plumbing.Reference) (gitypes.Commits, error) { - hash, err := r.targetHash(ref) +// HEAD returns the reference for a repository. +func (r *Repo) HEAD() (*git.Reference, error) { + if r.head != nil { + return r.head, nil + } + h, err := r.repository.HEAD() if err != nil { return nil, err } - // return cached commits if available - commits, ok := r.refCommits[hash] - if ok { - return commits, nil + r.head = h + return h, nil +} + +// GetReferences returns the references for a repository. +func (r *Repo) References() ([]*git.Reference, error) { + if r.refs != nil { + return r.refs, nil } - commits = gitypes.Commits{} - co, err := r.commitForHash(hash) + refs, err := r.repository.References() if err != nil { return nil, err } - // traverse the commit tree to get all commits - commits = append(commits, co) - for co.NumParents() > 0 { - co, err = r.commitForHash(co.ParentHashes[0]) - if err != nil { - return nil, err - } - commits = append(commits, co) + r.refs = refs + return refs, nil +} + +// Tree returns the git tree for a given path. +func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) { + return r.repository.TreePath(ref, path) +} + +// Diff returns the diff for a given commit. +func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) { + hash := commit.Hash.String() + c, ok := r.patchCache.Get(hash) + if ok { + return c.(*git.Diff), nil } + diff, err := r.repository.Diff(commit) if err != nil { return nil, err } - sort.Sort(commits) - // cache the commits in the repo - r.refCommits[hash] = commits - return commits, nil + r.patchCache.Add(hash, diff) + return diff, nil } -// targetHash returns the target hash for a given reference. If reference is an -// annotated tag, find the target hash for that tag. -func (r *Repo) targetHash(ref *plumbing.Reference) (plumbing.Hash, error) { - hash := ref.Hash() - if ref.Type() != plumbing.HashReference { - return plumbing.ZeroHash, plumbing.ErrInvalidType - } - if ref.Name().IsTag() { - to, err := r.repository.TagObject(hash) - switch err { - case nil: - // annotated tag (object has a target hash) - hash = to.Target - case plumbing.ErrObjectNotFound: - // lightweight tag (hash points to a commit) - default: - return plumbing.ZeroHash, err - } +// CountCommits returns the number of commits for a repository. +func (r *Repo) CountCommits(ref *git.Reference) (int64, error) { + tc, err := r.repository.CountCommits(ref) + if err != nil { + return 0, err } - return hash, nil + return tc, nil } -// GetReadme returns the readme for a repository. -func (r *Repo) GetReadme() string { - return r.Readme +// CommitsByPage returns the commits for a repository. +func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) { + return r.repository.CommitsByPage(ref, page, size) } -// GetReadmePath returns the path to the readme for a repository. -func (r *Repo) GetReadmePath() string { - return r.ReadmePath +// Push pushes the repository to the remote. +func (r *Repo) Push(remote, branch string) error { + return r.repository.Push(remote, branch) } // RepoSource is a reference to an on-disk repositories. type RepoSource struct { Path string mtx sync.Mutex - repos []*Repo + repos map[string]*Repo } // NewRepoSource creates a new RepoSource. @@ -226,6 +148,7 @@ func NewRepoSource(repoPath string) *RepoSource { log.Fatal(err) } rs := &RepoSource{Path: repoPath} + rs.repos = make(map[string]*Repo, 0) return rs } @@ -233,19 +156,22 @@ func NewRepoSource(repoPath string) *RepoSource { func (rs *RepoSource) AllRepos() []*Repo { rs.mtx.Lock() defer rs.mtx.Unlock() - return rs.repos + repos := make([]*Repo, 0, len(rs.repos)) + for _, r := range rs.repos { + repos = append(repos, r) + } + return repos } // GetRepo returns a repository by name. func (rs *RepoSource) GetRepo(name string) (*Repo, error) { rs.mtx.Lock() defer rs.mtx.Unlock() - for _, r := range rs.repos { - if filepath.Base(r.path) == name { - return r, nil - } + r, ok := rs.repos[name] + if !ok { + return nil, ErrMissingRepo } - return nil, ErrMissingRepo + return r, nil } // InitRepo initializes a new Git repository. @@ -253,142 +179,75 @@ func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) { rs.mtx.Lock() defer rs.mtx.Unlock() rp := filepath.Join(rs.Path, name) - rg, err := git.PlainInit(rp, bare) + rg, err := git.Init(rp, bare) if err != nil { return nil, err } - if bare { - // Clone repo into memory storage - ar, err := git.Clone(memory.NewStorage(), memfs.New(), &git.CloneOptions{ - URL: rp, - }) - if err != nil && err != transport.ErrEmptyRemoteRepository { - return nil, err - } - rg = ar - } r := &Repo{ path: rp, repository: rg, + refs: []*git.Reference{ + git.NewReference(rp, git.RefsHeads+"master"), + }, } - rs.repos = append(rs.repos, r) + rs.repos[name] = r return r, nil } -// LoadRepos opens Git repositories. -func (rs *RepoSource) LoadRepos() error { +// LoadRepo loads a repository from disk. +func (rs *RepoSource) LoadRepo(name string) error { rs.mtx.Lock() defer rs.mtx.Unlock() + rp := filepath.Join(rs.Path, name) + r, err := rs.Open(rp) + if err != nil { + return err + } + rs.repos[name] = r + return nil +} + +// LoadRepos opens Git repositories. +func (rs *RepoSource) LoadRepos() error { rd, err := os.ReadDir(rs.Path) if err != nil { return err } - rs.repos = make([]*Repo, 0) for _, de := range rd { - rp := filepath.Join(rs.Path, de.Name()) - rg, err := git.PlainOpen(rp) + err = rs.LoadRepo(de.Name()) if err != nil { return err } - r, err := rs.loadRepo(rp, rg) - if err != nil { - return err - } - rs.repos = append(rs.repos, r) } return nil } -func (rs *RepoSource) loadRepo(path string, rg *git.Repository) (*Repo, error) { - r := &Repo{ - path: path, - repository: rg, - patch: make(map[plumbing.Hash]*object.Patch), - } - r.commits = make(map[plumbing.Hash]*object.Commit) - r.trees = make(map[plumbing.Hash]*object.Tree) - r.refCommits = make(map[plumbing.Hash]gitypes.Commits) - ref, err := rg.Head() - if err != nil { - return nil, err - } - r.head = ref - l, err := r.repository.Log(&git.LogOptions{All: true}) - if err != nil { - return nil, err - } - err = l.ForEach(func(c *object.Commit) error { - r.commits[c.Hash] = c - return nil - }) - if err != nil { - return nil, err - } - refs := make([]*plumbing.Reference, 0) - ri, err := rg.References() - if err != nil { - return nil, err - } - ri.ForEach(func(r *plumbing.Reference) error { - refs = append(refs, r) - return nil - }) - r.refs = refs - return r, nil -} - -// FindLatestFile returns the latest file for a given path. -func (r *Repo) FindLatestFile(pattern string) (*object.File, error) { - g, err := glob.Compile(pattern) +// LatestFile returns the contents of the latest file at the specified path in the repository. +func (r *Repo) LatestFile(pattern string) (string, string, error) { + g := glob.MustCompile(pattern) + dir := filepath.Dir(pattern) + t, err := r.repository.TreePath(r.head, dir) if err != nil { - return nil, err + return "", "", err } - c, err := r.commitForHash(r.head.Hash()) + ents, err := t.Entries() if err != nil { - return nil, err + return "", "", err } - 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 + for _, e := range ents { + fp := filepath.Join(dir, e.Name()) + if g.Match(fp) { + bts, err := e.Contents() + if err != nil { + return "", "", err + } + return string(bts), fp, nil } - 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 - } - content, err := f.Contents() - if err != nil { - return "", err - } - return content, nil -} - -// LatestTree returns the latest tree at the specified path in the repository. -func (r *Repo) LatestTree(path string) (*object.Tree, error) { - return r.Tree(r.head, path) + return "", "", git.ErrFileNotFound } // UpdateServerInfo updates the server info for the repository. func (r *Repo) UpdateServerInfo() error { - cmd := exec.Command("git", "update-server-info") - cmd.Dir = r.path - return cmd.Run() + return r.repository.UpdateServerInfo() } diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go index 321b0fe86a5c3879ff253aecd3fec4fd3a5e4f8e..bacc3de883538dd8cace59bbd1cce185b0b783bf 100644 --- a/internal/tui/bubble.go +++ b/internal/tui/bubble.go @@ -7,10 +7,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/soft-serve/internal/config" - gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection" "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/charmbracelet/soft-serve/pkg/tui/common" "github.com/gliderlabs/ssh" ) @@ -157,19 +157,19 @@ func (b Bubble) headerView() string { func (b Bubble) footerView() string { w := &strings.Builder{} - var h []gittypes.HelpEntry + var h []common.HelpEntry if b.state != errorState { - h = []gittypes.HelpEntry{ + h = []common.HelpEntry{ {Key: "tab", Value: "section"}, } - if box, ok := b.boxes[b.activeBox].(gittypes.BubbleHelper); ok { + if box, ok := b.boxes[b.activeBox].(common.BubbleHelper); ok { help := box.Help() for _, he := range help { h = append(h, he) } } } - h = append(h, gittypes.HelpEntry{Key: "q", Value: "quit"}) + h = append(h, common.HelpEntry{Key: "q", Value: "quit"}) for i, v := range h { fmt.Fprint(w, helpEntryRender(v, b.styles)) if i != len(h)-1 { @@ -178,13 +178,14 @@ func (b Bubble) footerView() string { } branch := "" if b.state == loadedState { - branch = b.boxes[1].(*repo.Bubble).Reference().Short() + ref := b.boxes[1].(*repo.Bubble).Reference() + branch = ref.Name().Short() } help := w.String() branchMaxWidth := b.width - // bubble width lipgloss.Width(help) - // help width b.styles.App.GetHorizontalFrameSize() // App paddings - branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…")) + branch = b.styles.Branch.Render(common.TruncateString(branch, branchMaxWidth-1, "…")) gap := lipgloss.NewStyle(). Width(b.width - lipgloss.Width(help) - @@ -228,6 +229,6 @@ func (b Bubble) View() string { return b.styles.App.Render(s.String()) } -func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string { +func helpEntryRender(h common.HelpEntry, s *style.Styles) string { return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value)) } diff --git a/internal/tui/bubbles/git/types/git.go b/internal/tui/bubbles/git/types/git.go deleted file mode 100644 index 90e5436e98a21a06413dd177dd5f085c59eac91b..0000000000000000000000000000000000000000 --- a/internal/tui/bubbles/git/types/git.go +++ /dev/null @@ -1,30 +0,0 @@ -package types - -import ( - "context" - - "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" -) - -type Repo interface { - Name() string - GetHEAD() *plumbing.Reference - 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) - PatchCtx(context.Context, *object.Commit) (*object.Patch, error) -} - -type Commits []*object.Commit - -func (cl Commits) Len() int { return len(cl) } -func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } -func (cl Commits) Less(i, j int) bool { - return cl[i].Author.When.After(cl[j].Author.When) -} diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go index 5ba6f8fcacea26ea8af02c61007f1911802ab550..b8cdd0e2d7e689c671fa4d1f8576faf65cb8d14f 100644 --- a/internal/tui/bubbles/repo/bubble.go +++ b/internal/tui/bubbles/repo/bubble.go @@ -6,10 +6,10 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - gitui "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git" - gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/go-git/go-git/v5/plumbing" + "github.com/charmbracelet/soft-serve/pkg/git" + gitui "github.com/charmbracelet/soft-serve/pkg/tui" + "github.com/charmbracelet/soft-serve/pkg/tui/common" "github.com/muesli/reflow/truncate" "github.com/muesli/reflow/wrap" ) @@ -22,7 +22,7 @@ type Bubble struct { name string host string port int - repo gittypes.Repo + repo common.GitRepo styles *style.Styles width int widthMargin int @@ -33,7 +33,7 @@ type Bubble struct { Active bool } -func NewBubble(repo gittypes.Repo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble { +func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble { b := &Bubble{ name: repo.Name(), host: host, @@ -56,6 +56,9 @@ func (b *Bubble) Init() tea.Cmd { func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: + if msg.Width == b.width && msg.Height == b.height { + return b, nil + } b.width = msg.Width b.height = msg.Height } @@ -64,7 +67,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return b, cmd } -func (b *Bubble) Help() []gittypes.HelpEntry { +func (b *Bubble) Help() []common.HelpEntry { return b.box.Help() } @@ -95,7 +98,7 @@ func (b Bubble) headerView() string { note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note) // Render borders on name and command - height := gittypes.Max(lipgloss.Height(title), lipgloss.Height(note)) + height := common.Max(lipgloss.Height(title), lipgloss.Height(note)) titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height) noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height) if b.Active { @@ -121,7 +124,7 @@ func (b *Bubble) View() string { return header + body } -func (b *Bubble) Reference() plumbing.ReferenceName { +func (b *Bubble) Reference() *git.Reference { return b.box.Reference() } diff --git a/internal/tui/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go index eb84b5d6817ac5174b92a1c187ca3eae14546d94..866de2db81e189426cfad9bc9f1f89f02140c89c 100644 --- a/internal/tui/bubbles/selection/bubble.go +++ b/internal/tui/bubbles/selection/bubble.go @@ -5,8 +5,8 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/charmbracelet/soft-serve/pkg/tui/common" "github.com/muesli/reflow/truncate" ) @@ -80,8 +80,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return b, tea.Batch(cmds...) } -func (b *Bubble) Help() []gittypes.HelpEntry { - return []gittypes.HelpEntry{ +func (b *Bubble) Help() []common.HelpEntry { + return []common.HelpEntry{ {Key: "↑/↓", Value: "navigate"}, } } diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 2008ae811db8f59d58152979d133c03aea521fd4..e95bfc3dcbd6eba275a4e26c91e11fdef1779b3c 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -5,9 +5,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo" "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection" + "github.com/charmbracelet/soft-serve/pkg/tui/common" gm "github.com/charmbracelet/wish/git" ) @@ -110,7 +110,7 @@ func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) { initCmd := rb.Init() msg := initCmd() switch msg := msg.(type) { - case gitypes.ErrMsg: + case common.ErrMsg: return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error()) } me.bubble = rb diff --git a/pkg/git/commit.go b/pkg/git/commit.go new file mode 100644 index 0000000000000000000000000000000000000000..6a7d2778064dd35923e92788eab19f75048a201c --- /dev/null +++ b/pkg/git/commit.go @@ -0,0 +1,42 @@ +package git + +import ( + "github.com/gogs/git-module" +) + +var ( + ZeroHash Hash = git.EmptyID +) + +// Hash represents a git hash. +type Hash string + +// String returns the string representation of a hash as a string. +func (h Hash) String() string { + return string(h) +} + +// SHA1 represents the hash as a SHA1. +func (h Hash) SHA1() *git.SHA1 { + return git.MustIDFromString(h.String()) +} + +// Commit is a wrapper around git.Commit with helper methods. +type Commit struct { + *git.Commit + Hash Hash +} + +// Commits is a list of commits. +type Commits []*Commit + +// Len implements sort.Interface. +func (cl Commits) Len() int { return len(cl) } + +// Swap implements sort.Interface. +func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } + +// Less implements sort.Interface. +func (cl Commits) Less(i, j int) bool { + return cl[i].Author.When.After(cl[j].Author.When) +} diff --git a/pkg/git/errors.go b/pkg/git/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..9f8b705d77c864e5cdae5adc760c12ce1955ccd9 --- /dev/null +++ b/pkg/git/errors.go @@ -0,0 +1,9 @@ +package git + +import "errors" + +var ( + ErrFileNotFound = errors.New("file not found") + ErrDirectoryNotFound = errors.New("directory not found") + ErrReferenceNotFound = errors.New("reference not found") +) diff --git a/pkg/git/patch.go b/pkg/git/patch.go new file mode 100644 index 0000000000000000000000000000000000000000..f2f3e1cc4b3008a7402e5f6759fe5c88747b8311 --- /dev/null +++ b/pkg/git/patch.go @@ -0,0 +1,329 @@ +package git + +import ( + "bytes" + "fmt" + "math" + "strings" + "sync" + + "github.com/dustin/go-humanize/english" + "github.com/gogs/git-module" + "github.com/sergi/go-diff/diffmatchpatch" +) + +// DiffSection is a wrapper to git.DiffSection with helper methods. +type DiffSection struct { + *git.DiffSection + + initOnce sync.Once + dmp *diffmatchpatch.DiffMatchPatch +} + +// diffFor computes inline diff for the given line. +func (s *DiffSection) diffFor(line *git.DiffLine) string { + fallback := line.Content + + // Find equivalent diff line, ignore when not found. + var diff1, diff2 string + switch line.Type { + case git.DiffLineAdd: + compareLine := s.Line(git.DiffLineDelete, line.RightLine) + if compareLine == nil { + return fallback + } + + diff1 = compareLine.Content + diff2 = line.Content + + case git.DiffLineDelete: + compareLine := s.Line(git.DiffLineAdd, line.LeftLine) + if compareLine == nil { + return fallback + } + + diff1 = line.Content + diff2 = compareLine.Content + + default: + return fallback + } + + s.initOnce.Do(func() { + s.dmp = diffmatchpatch.New() + s.dmp.DiffEditCost = 100 + }) + + diffs := s.dmp.DiffMain(diff1[1:], diff2[1:], true) + diffs = s.dmp.DiffCleanupEfficiency(diffs) + + return diffsToString(diffs, line.Type) +} + +func diffsToString(diffs []diffmatchpatch.Diff, lineType git.DiffLineType) string { + buf := bytes.NewBuffer(nil) + + // Reproduce signs which are cutted for inline diff before. + switch lineType { + case git.DiffLineAdd: + buf.WriteByte('+') + case git.DiffLineDelete: + buf.WriteByte('-') + } + + for i := range diffs { + switch { + case diffs[i].Type == diffmatchpatch.DiffInsert && lineType == git.DiffLineAdd: + buf.WriteString(diffs[i].Text) + case diffs[i].Type == diffmatchpatch.DiffDelete && lineType == git.DiffLineDelete: + buf.WriteString(diffs[i].Text) + case diffs[i].Type == diffmatchpatch.DiffEqual: + buf.WriteString(diffs[i].Text) + } + } + + return string(buf.Bytes()) +} + +// DiffFile is a wrapper to git.DiffFile with helper methods. +type DiffFile struct { + *git.DiffFile + Sections []*DiffSection +} + +type DiffFileChange struct { + hash string + name string + mode git.EntryMode +} + +func (f *DiffFileChange) Hash() string { + return f.hash +} + +func (f *DiffFileChange) Name() string { + return f.name +} + +func (f *DiffFileChange) Mode() git.EntryMode { + return f.mode +} + +func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) { + if f.OldIndex != ZeroHash.String() { + from = &DiffFileChange{ + hash: f.OldIndex, + name: f.OldName(), + mode: f.OldMode(), + } + } + if f.Index != ZeroHash.String() { + to = &DiffFileChange{ + hash: f.Index, + name: f.Name, + mode: f.Mode(), + } + } + return +} + +// FileStats +type FileStats []*DiffFile + +// String returns a string representation of file stats. +func (fs FileStats) String() string { + return printStats(fs) +} + +func printStats(stats FileStats) string { + padLength := float64(len(" ")) + newlineLength := float64(len("\n")) + separatorLength := float64(len("|")) + // Soft line length limit. The text length calculation below excludes + // length of the change number. Adding that would take it closer to 80, + // but probably not more than 80, until it's a huge number. + lineLength := 72.0 + + // Get the longest filename and longest total change. + var longestLength float64 + var longestTotalChange float64 + for _, fs := range stats { + if int(longestLength) < len(fs.Name) { + longestLength = float64(len(fs.Name)) + } + totalChange := fs.NumAdditions() + fs.NumDeletions() + if int(longestTotalChange) < totalChange { + longestTotalChange = float64(totalChange) + } + } + + // Parts of the output: + // |<+++/---> + // example: " main.go | 10 +++++++--- " + + // + leftTextLength := padLength + longestLength + padLength + + // <+++++/-----> + // Excluding number length here. + rightTextLength := padLength + padLength + newlineLength + + totalTextArea := leftTextLength + separatorLength + rightTextLength + heightOfHistogram := lineLength - totalTextArea + + // Scale the histogram. + var scaleFactor float64 + if longestTotalChange > heightOfHistogram { + // Scale down to heightOfHistogram. + scaleFactor = longestTotalChange / heightOfHistogram + } else { + scaleFactor = 1.0 + } + + taddc := 0 + tdelc := 0 + output := strings.Builder{} + for _, fs := range stats { + taddc += fs.NumAdditions() + tdelc += fs.NumDeletions() + addn := float64(fs.NumAdditions()) + deln := float64(fs.NumDeletions()) + addc := int(math.Floor(addn / scaleFactor)) + delc := int(math.Floor(deln / scaleFactor)) + if addc < 0 { + addc = 0 + } + if delc < 0 { + delc = 0 + } + adds := strings.Repeat("+", addc) + dels := strings.Repeat("-", delc) + diffLines := fmt.Sprint(fs.NumAdditions() + fs.NumDeletions()) + totalDiffLines := fmt.Sprint(int(longestTotalChange)) + fmt.Fprintf(&output, "%s | %s %s%s\n", + fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)), + strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines, + adds, + dels) + } + files := len(stats) + fc := fmt.Sprintf("%s changed", english.Plural(files, "file", "")) + ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", "")) + dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", "")) + fmt.Fprint(&output, fc) + if taddc > 0 { + fmt.Fprintf(&output, ", %s", ins) + } + if tdelc > 0 { + fmt.Fprintf(&output, ", %s", dels) + } + fmt.Fprint(&output, "\n") + + return output.String() +} + +// Diff is a wrapper around git.Diff with helper methods. +type Diff struct { + *git.Diff + Files []*DiffFile +} + +// FileStats returns the diff file stats. +func (d *Diff) Stats() FileStats { + return d.Files +} + +const ( + dstPrefix = "b/" + srcPrefix = "a/" +) + +func appendPathLines(lines []string, fromPath, toPath string, isBinary bool) []string { + if isBinary { + return append(lines, + fmt.Sprintf("Binary files %s and %s differ", fromPath, toPath), + ) + } + return append(lines, + fmt.Sprintf("--- %s", fromPath), + fmt.Sprintf("+++ %s", toPath), + ) +} + +func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) { + from, to := filePatch.Files() + if from == nil && to == nil { + return + } + isBinary := filePatch.IsBinary() + + var lines []string + switch { + case from != nil && to != nil: + hashEquals := from.Hash() == to.Hash() + lines = append(lines, + fmt.Sprintf("diff --git %s%s %s%s", + srcPrefix, from.Name(), dstPrefix, to.Name()), + ) + if from.Mode() != to.Mode() { + lines = append(lines, + fmt.Sprintf("old mode %o", from.Mode()), + fmt.Sprintf("new mode %o", to.Mode()), + ) + } + if from.Name() != to.Name() { + lines = append(lines, + fmt.Sprintf("rename from %s", from.Name()), + fmt.Sprintf("rename to %s", to.Name()), + ) + } + if from.Mode() != to.Mode() && !hashEquals { + lines = append(lines, + fmt.Sprintf("index %s..%s", from.Hash(), to.Hash()), + ) + } else if !hashEquals { + lines = append(lines, + fmt.Sprintf("index %s..%s %o", from.Hash(), to.Hash(), from.Mode()), + ) + } + if !hashEquals { + lines = appendPathLines(lines, srcPrefix+from.Name(), dstPrefix+to.Name(), isBinary) + } + case from == nil: + lines = append(lines, + fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()), + fmt.Sprintf("new file mode %o", to.Mode()), + fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()), + ) + lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary) + case to == nil: + lines = append(lines, + fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()), + fmt.Sprintf("deleted file mode %o", from.Mode()), + fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash), + ) + lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary) + } + + sb.WriteString(lines[0]) + for _, line := range lines[1:] { + sb.WriteByte('\n') + sb.WriteString(line) + } + sb.WriteByte('\n') +} + +// Patch returns the diff as a patch. +func (d *Diff) Patch() string { + var p strings.Builder + for _, f := range d.Files { + writeFilePatchHeader(&p, f) + for _, s := range f.Sections { + for _, l := range s.Lines { + p.WriteString(s.diffFor(l)) + p.WriteString("\n") + } + } + } + return p.String() +} diff --git a/pkg/git/reference.go b/pkg/git/reference.go new file mode 100644 index 0000000000000000000000000000000000000000..ec01a53fdc8c70f52283107c33029ae5fa57577d --- /dev/null +++ b/pkg/git/reference.go @@ -0,0 +1,76 @@ +package git + +import ( + "strings" + + "github.com/gogs/git-module" +) + +const ( + // HEAD represents the name of the HEAD reference. + HEAD = "HEAD" + // RefsHeads represents the prefix for branch references. + RefsHeads = git.RefsHeads + // RefsTags represents the prefix for tag references. + RefsTags = git.RefsTags +) + +// Reference is a wrapper around git.Reference with helper methods. +type Reference struct { + *git.Reference + Hash Hash + path string // repo path +} + +// ReferenceName is a Refspec wrapper. +type ReferenceName string + +// NewReference creates a new reference. +func NewReference(rp, refspec string) *Reference { + return &Reference{ + Reference: &git.Reference{ + Refspec: refspec, + }, + path: rp, + } +} + +// String returns the reference name i.e. refs/heads/master. +func (r ReferenceName) String() string { + return string(r) +} + +// Short returns the short name of the reference i.e. master. +func (r ReferenceName) Short() string { + s := strings.Split(r.String(), "/") + if len(s) > 0 { + return s[len(s)-1] + } + return r.String() +} + +// Name returns the reference name i.e. refs/heads/master. +func (r *Reference) Name() ReferenceName { + return ReferenceName(r.Refspec) +} + +// IsBranch returns true if the reference is a branch. +func (r *Reference) IsBranch() bool { + return strings.HasPrefix(r.Refspec, git.RefsHeads) +} + +// IsTag returns true if the reference is a tag. +func (r *Reference) IsTag() bool { + return strings.HasPrefix(r.Refspec, git.RefsTags) +} + +// TargetHash returns the hash of the reference target. +func (r *Reference) TargetHash() Hash { + if r.IsTag() { + id, err := git.ShowRefVerify(r.path, r.Refspec) + if err == nil { + return Hash(id) + } + } + return r.Hash +} diff --git a/pkg/git/repo.go b/pkg/git/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..dc749c797dc1383e7851d46257b1184343dc1646 --- /dev/null +++ b/pkg/git/repo.go @@ -0,0 +1,185 @@ +package git + +import ( + "path/filepath" + + "github.com/gogs/git-module" +) + +var ( + DiffMaxFiles = 1000 + DiffMaxFileLines = 1000 + DiffMaxLineChars = 1000 +) + +// Repository is a wrapper around git.Repository with helper methods. +type Repository struct { + *git.Repository + Path string +} + +// Clone clones a repository. +func Clone(src, dst string, opts ...git.CloneOptions) error { + return git.Clone(src, dst, opts...) +} + +// Init initializes and opens a new git repository. +func Init(path string, bare bool) (*Repository, error) { + err := git.Init(path, git.InitOptions{Bare: bare}) + if err != nil { + return nil, err + } + return Open(path) +} + +// Open opens a git repository at the given path. +func Open(path string) (*Repository, error) { + repo, err := git.Open(path) + if err != nil { + return nil, err + } + return &Repository{ + Repository: repo, + Path: path, + }, nil +} + +// Name returns the name of the repository. +func (r *Repository) Name() string { + return filepath.Base(r.Path) +} + +// HEAD returns the HEAD reference for a repository. +func (r *Repository) HEAD() (*Reference, error) { + rn, err := r.SymbolicRef() + if err != nil { + return nil, err + } + hash, err := r.ShowRefVerify(rn) + if err != nil { + return nil, err + } + return &Reference{ + Reference: &git.Reference{ + ID: hash, + Refspec: rn, + }, + Hash: Hash(hash), + path: r.Path, + }, nil +} + +// References returns the references for a repository. +func (r *Repository) References() ([]*Reference, error) { + refs, err := r.ShowRef() + if err != nil { + return nil, err + } + rrefs := make([]*Reference, 0, len(refs)) + for _, ref := range refs { + rrefs = append(rrefs, &Reference{ + Reference: ref, + Hash: Hash(ref.ID), + path: r.Path, + }) + } + return rrefs, nil +} + +// Tree returns the tree for the given reference. +func (r *Repository) Tree(ref *Reference) (*Tree, error) { + if ref == nil { + rref, err := r.HEAD() + if err != nil { + return nil, err + } + ref = rref + } + tree, err := r.LsTree(ref.Hash.String()) + if err != nil { + return nil, err + } + return &Tree{ + Tree: tree, + Path: "", + }, nil +} + +// TreePath returns the tree for the given path. +func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) { + path = filepath.Clean(path) + if path == "." { + path = "" + } + if path == "" { + return r.Tree(ref) + } + t, err := r.Tree(ref) + if err != nil { + return nil, err + } + return t.SubTree(path) +} + +// Diff returns the diff for the given commit. +func (r *Repository) Diff(commit *Commit) (*Diff, error) { + ddiff, err := r.Repository.Diff(commit.Hash.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars) + if err != nil { + return nil, err + } + files := make([]*DiffFile, 0, len(ddiff.Files)) + for _, df := range ddiff.Files { + sections := make([]*DiffSection, 0, len(df.Sections)) + for _, ds := range df.Sections { + sections = append(sections, &DiffSection{ + DiffSection: ds, + }) + } + files = append(files, &DiffFile{ + DiffFile: df, + Sections: sections, + }) + } + diff := &Diff{ + Diff: ddiff, + Files: files, + } + return diff, nil +} + +// Patch returns the patch for the given reference. +func (r *Repository) Patch(commit *Commit) (string, error) { + diff, err := r.Diff(commit) + if err != nil { + return "", err + } + return diff.Patch(), err +} + +// CountCommits returns the number of commits in the repository. +func (r *Repository) CountCommits(ref *Reference) (int64, error) { + return r.Repository.RevListCount([]string{ref.Name().String()}) +} + +// CommitsByPage returns the commits for a given page and size. +func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, error) { + cs, err := r.Repository.CommitsByPage(ref.Name().String(), page, size) + if err != nil { + return nil, err + } + commits := make(Commits, len(cs)) + for i, c := range cs { + commits[i] = &Commit{ + Commit: c, + Hash: Hash(c.ID.String()), + } + } + return commits, nil +} + +// UpdateServerInfo updates the repository server info. +func (r *Repository) UpdateServerInfo() error { + cmd := git.NewCommand("update-server-info") + _, err := cmd.RunInDir(r.Path) + return err +} diff --git a/pkg/git/tree.go b/pkg/git/tree.go new file mode 100644 index 0000000000000000000000000000000000000000..517c9effb9ea6df86b1c80b7003837a52a190365 --- /dev/null +++ b/pkg/git/tree.go @@ -0,0 +1,191 @@ +package git + +import ( + "bufio" + "bytes" + "io" + "io/fs" + "path/filepath" + "sort" + + "github.com/gogs/git-module" +) + +// Tree is a wrapper around git.Tree with helper methods. +type Tree struct { + *git.Tree + Path string +} + +// TreeEntry is a wrapper around git.TreeEntry with helper methods. +type TreeEntry struct { + *git.TreeEntry + // path is the full path of the file + path string +} + +// Entries is a wrapper around git.Entries +type Entries []*TreeEntry + +var sorters = []func(t1, t2 *TreeEntry) bool{ + func(t1, t2 *TreeEntry) bool { + return (t1.IsTree() || t1.IsCommit()) && !t2.IsTree() && !t2.IsCommit() + }, + func(t1, t2 *TreeEntry) bool { + return t1.Name() < t2.Name() + }, +} + +// Len implements sort.Interface. +func (es Entries) Len() int { return len(es) } + +// Swap implements sort.Interface. +func (es Entries) Swap(i, j int) { es[i], es[j] = es[j], es[i] } + +// Less implements sort.Interface. +func (es Entries) Less(i, j int) bool { + t1, t2 := es[i], es[j] + var k int + for k = 0; k < len(sorters)-1; k++ { + sorter := sorters[k] + switch { + case sorter(t1, t2): + return true + case sorter(t2, t1): + return false + } + } + return sorters[k](t1, t2) +} + +// Sort sorts the entries in the tree. +func (es Entries) Sort() { + sort.Sort(es) +} + +// File is a wrapper around git.Blob with helper methods. +type File struct { + *git.Blob + Entry *TreeEntry +} + +// Name returns the name of the file. +func (f *File) Name() string { + return f.Entry.Name() +} + +// Path returns the full path of the file. +func (f *File) Path() string { + return f.Entry.path +} + +// SubTree returns the sub-tree at the given path. +func (t *Tree) SubTree(path string) (*Tree, error) { + tree, err := t.Subtree(path) + if err != nil { + return nil, err + } + return &Tree{ + Tree: tree, + Path: path, + }, nil +} + +// Entries returns the entries in the tree. +func (t *Tree) Entries() (Entries, error) { + entries, err := t.Tree.Entries() + if err != nil { + return nil, err + } + ret := make(Entries, len(entries)) + for i, e := range entries { + ret[i] = &TreeEntry{ + TreeEntry: e, + path: filepath.Join(t.Path, e.Name()), + } + } + return ret, nil +} + +func (t *Tree) TreeEntry(path string) (*TreeEntry, error) { + entry, err := t.Tree.TreeEntry(path) + if err != nil { + return nil, err + } + return &TreeEntry{ + TreeEntry: entry, + path: filepath.Join(t.Path, entry.Name()), + }, nil +} + +const sniffLen = 8000 + +// IsBinary detects if data is a binary value based on: +// http://git.kernel.org/cgit/git/git.git/tree/xdiff-interface.c?id=HEAD#n198 +func IsBinary(r io.Reader) (bool, error) { + reader := bufio.NewReader(r) + c := 0 + for { + if c == sniffLen { + break + } + + b, err := reader.ReadByte() + if err == io.EOF { + break + } + if err != nil { + return false, err + } + + if b == byte(0) { + return true, nil + } + + c++ + } + + return false, nil +} + +// IsBinary returns true if the file is binary. +func (f *File) IsBinary() (bool, error) { + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + err := f.Pipeline(stdout, stderr) + if err != nil { + return false, err + } + r := bufio.NewReader(stdout) + return IsBinary(r) +} + +// Mode returns the mode of the file in fs.FileMode format. +func (e *TreeEntry) Mode() fs.FileMode { + m := e.Blob().Mode() + switch m { + case git.EntryTree: + return fs.ModeDir | fs.ModePerm + default: + return fs.FileMode(m) + } +} + +// File returns the file for the TreeEntry. +func (e *TreeEntry) File() *File { + b := e.Blob() + return &File{ + Blob: b, + Entry: e, + } +} + +// Contents returns the contents of the file. +func (e *TreeEntry) Contents() ([]byte, error) { + return e.File().Contents() +} + +// Contents returns the contents of the file. +func (f *File) Contents() ([]byte, error) { + return f.Blob.Bytes() +} diff --git a/internal/tui/bubbles/git/about/bubble.go b/pkg/tui/about/bubble.go similarity index 76% rename from internal/tui/bubbles/git/about/bubble.go rename to pkg/tui/about/bubble.go index 2c957397d0bde01e0911261bb5dd5d4cfefc1efc..bc819af6ab6d267e9f4e6afde4f23f12e25d7018 100644 --- a/internal/tui/bubbles/git/about/bubble.go +++ b/pkg/tui/about/bubble.go @@ -3,26 +3,26 @@ package about import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" - 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/charmbracelet/soft-serve/pkg/git" + "github.com/charmbracelet/soft-serve/pkg/tui/common" + "github.com/charmbracelet/soft-serve/pkg/tui/refs" + vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport" "github.com/muesli/reflow/wrap" ) type Bubble struct { readmeViewport *vp.ViewportBubble - repo types.Repo + repo common.GitRepo styles *style.Styles height int heightMargin int width int widthMargin int - ref *plumbing.Reference + ref *git.Reference } -func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble { +func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble { b := &Bubble{ readmeViewport: &vp.ViewportBubble{ Viewport: &viewport.Model{}, @@ -31,13 +31,12 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) styles: styles, widthMargin: wm, heightMargin: hm, - ref: repo.GetHEAD(), } b.SetSize(width, height) return b } func (b *Bubble) Init() tea.Cmd { - return b.setupCmd + return b.reset } func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -56,11 +55,11 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "R": - b.GotoTop() + return b, b.reset } case refs.RefMsg: b.ref = msg - return b, b.setupCmd + return b, b.reset } rv, cmd := b.readmeViewport.Update(msg) b.readmeViewport = rv.(*vp.ViewportBubble) @@ -83,17 +82,17 @@ func (b *Bubble) View() string { return b.readmeViewport.View() } -func (b *Bubble) Help() []types.HelpEntry { +func (b *Bubble) Help() []common.HelpEntry { return nil } func (b *Bubble) glamourize() (string, error) { w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize() - rm := b.repo.GetReadme() + rm, rp := b.repo.Readme() if rm == "" { return b.styles.AboutNoReadme.Render("No readme found."), nil } - f, err := types.RenderFile(b.repo.GetReadmePath(), rm, w) + f, err := common.RenderFile(rp, rm, w) if err != nil { return "", err } @@ -107,11 +106,16 @@ func (b *Bubble) glamourize() (string, error) { return wrap.String(f, w), nil } -func (b *Bubble) setupCmd() tea.Msg { +func (b *Bubble) reset() tea.Msg { md, err := b.glamourize() if err != nil { - return types.ErrMsg{Err: err} + return common.ErrMsg{Err: err} } + head, err := b.repo.HEAD() + if err != nil { + return common.ErrMsg{Err: err} + } + b.ref = head b.readmeViewport.Viewport.SetContent(md) b.GotoTop() return nil diff --git a/internal/tui/bubbles/git/bubble.go b/pkg/tui/bubble.go similarity index 70% rename from internal/tui/bubbles/git/bubble.go rename to pkg/tui/bubble.go index 6b5ea700c42478615b56e36c06f7a554e3cc4e0f..40a3ace61c52730d7fa208dbf2dbcd5c47420a49 100644 --- a/internal/tui/bubbles/git/bubble.go +++ b/pkg/tui/bubble.go @@ -1,15 +1,15 @@ -package git +package tui import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/about" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/log" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/tree" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/go-git/go-git/v5/plumbing" + "github.com/charmbracelet/soft-serve/pkg/git" + "github.com/charmbracelet/soft-serve/pkg/tui/about" + "github.com/charmbracelet/soft-serve/pkg/tui/common" + "github.com/charmbracelet/soft-serve/pkg/tui/log" + "github.com/charmbracelet/soft-serve/pkg/tui/refs" + "github.com/charmbracelet/soft-serve/pkg/tui/tree" ) const ( @@ -27,17 +27,17 @@ const ( type Bubble struct { state state - repo types.Repo + repo common.GitRepo height int heightMargin int width int widthMargin int style *style.Styles boxes []tea.Model - ref *plumbing.Reference + ref *git.Reference } -func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble { +func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble { b := &Bubble{ repo: repo, state: aboutState, @@ -47,7 +47,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) heightMargin: hm, style: styles, boxes: make([]tea.Model, 4), - ref: repo.GetHEAD(), } heightMargin := hm + lipgloss.Height(b.headerView()) b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin) @@ -106,20 +105,20 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return b, tea.Batch(cmds...) } -func (b *Bubble) Help() []types.HelpEntry { - h := []types.HelpEntry{} - h = append(h, b.boxes[b.state].(types.BubbleHelper).Help()...) +func (b *Bubble) Help() []common.HelpEntry { + h := []common.HelpEntry{} + h = append(h, b.boxes[b.state].(common.BubbleHelper).Help()...) if b.repo.Name() != "config" { - h = append(h, types.HelpEntry{Key: "R", Value: "readme"}) - h = append(h, types.HelpEntry{Key: "F", Value: "files"}) - h = append(h, types.HelpEntry{Key: "C", Value: "commits"}) - h = append(h, types.HelpEntry{Key: "B", Value: "branches"}) + h = append(h, common.HelpEntry{Key: "R", Value: "readme"}) + h = append(h, common.HelpEntry{Key: "F", Value: "files"}) + h = append(h, common.HelpEntry{Key: "C", Value: "commits"}) + h = append(h, common.HelpEntry{Key: "B", Value: "branches"}) } return h } -func (b *Bubble) Reference() plumbing.ReferenceName { - return b.ref.Name() +func (b *Bubble) Reference() *git.Reference { + return b.ref } func (b *Bubble) headerView() string { @@ -133,6 +132,11 @@ func (b *Bubble) View() string { } func (b *Bubble) setupCmd() tea.Msg { + head, err := b.repo.HEAD() + if err != nil { + return common.ErrMsg{Err: err} + } + b.ref = head cmds := make([]tea.Cmd, 0) for _, bx := range b.boxes { if bx != nil { @@ -140,7 +144,7 @@ func (b *Bubble) setupCmd() tea.Msg { if initCmd != nil { msg := initCmd() switch msg := msg.(type) { - case types.ErrMsg: + case common.ErrMsg: return msg } } diff --git a/internal/tui/bubbles/git/types/consts.go b/pkg/tui/common/consts.go similarity index 97% rename from internal/tui/bubbles/git/types/consts.go rename to pkg/tui/common/consts.go index 940938638aa3082404ce339b8cdfbad01c52f7a2..c915df177fbfe6ca0c6c67c58ae246b39d633f6a 100644 --- a/internal/tui/bubbles/git/types/consts.go +++ b/pkg/tui/common/consts.go @@ -1,4 +1,4 @@ -package types +package common import ( "time" diff --git a/internal/tui/bubbles/git/types/error.go b/pkg/tui/common/error.go similarity index 98% rename from internal/tui/bubbles/git/types/error.go rename to pkg/tui/common/error.go index 0e2cf1e4621a00626bba67d2eda23e5ab231c158..b9ecc9594b04ed7a793fee776af2f3835ffa6715 100644 --- a/internal/tui/bubbles/git/types/error.go +++ b/pkg/tui/common/error.go @@ -1,4 +1,4 @@ -package types +package common import ( "errors" diff --git a/internal/tui/bubbles/git/types/formatter.go b/pkg/tui/common/formatter.go similarity index 99% rename from internal/tui/bubbles/git/types/formatter.go rename to pkg/tui/common/formatter.go index a7ef850e834284ddb0a41807ccd059429260d4a0..11143ec48a2694437cf63ae9e7728c2ad61198dc 100644 --- a/internal/tui/bubbles/git/types/formatter.go +++ b/pkg/tui/common/formatter.go @@ -1,4 +1,4 @@ -package types +package common import ( "strings" diff --git a/pkg/tui/common/git.go b/pkg/tui/common/git.go new file mode 100644 index 0000000000000000000000000000000000000000..64a371de644feef6adb5d41c00dda4ed59c28e2f --- /dev/null +++ b/pkg/tui/common/git.go @@ -0,0 +1,16 @@ +package common + +import ( + "github.com/charmbracelet/soft-serve/pkg/git" +) + +type GitRepo interface { + Name() string + Readme() (string, string) + HEAD() (*git.Reference, error) + CommitsByPage(*git.Reference, int, int) (git.Commits, error) + CountCommits(*git.Reference) (int64, error) + Diff(*git.Commit) (*git.Diff, error) + References() ([]*git.Reference, error) + Tree(*git.Reference, string) (*git.Tree, error) +} diff --git a/internal/tui/bubbles/git/types/help.go b/pkg/tui/common/help.go similarity index 87% rename from internal/tui/bubbles/git/types/help.go rename to pkg/tui/common/help.go index c42fa527270b56b469b5d029f933add804d2de99..5bc1a9a8df577879b048fa0bceee2dad4089676d 100644 --- a/internal/tui/bubbles/git/types/help.go +++ b/pkg/tui/common/help.go @@ -1,4 +1,4 @@ -package types +package common type BubbleHelper interface { Help() []HelpEntry diff --git a/internal/tui/bubbles/git/types/reset.go b/pkg/tui/common/reset.go similarity index 86% rename from internal/tui/bubbles/git/types/reset.go rename to pkg/tui/common/reset.go index 919ab3d896bcc1dfa7d677f7e5df94d9a6ae6a4e..fe92b47154c9fe0850563dea7e9f87550c281c36 100644 --- a/internal/tui/bubbles/git/types/reset.go +++ b/pkg/tui/common/reset.go @@ -1,4 +1,4 @@ -package types +package common import tea "github.com/charmbracelet/bubbletea" diff --git a/internal/tui/bubbles/git/types/utils.go b/pkg/tui/common/utils.go similarity index 94% rename from internal/tui/bubbles/git/types/utils.go rename to pkg/tui/common/utils.go index c1758097d235186a24d120676b42b667604f8fff..2cafee64e60735a350363506c578dcf2bb9cb193 100644 --- a/internal/tui/bubbles/git/types/utils.go +++ b/pkg/tui/common/utils.go @@ -1,4 +1,4 @@ -package types +package common import "github.com/muesli/reflow/truncate" diff --git a/internal/tui/bubbles/git/log/bubble.go b/pkg/tui/log/bubble.go similarity index 53% rename from internal/tui/bubbles/git/log/bubble.go rename to pkg/tui/log/bubble.go index 579876737765bc3ab00251642fb2076600478cae..43128d2bafbbcc3a6541f5f94f35e2e518a59418 100644 --- a/internal/tui/bubbles/git/log/bubble.go +++ b/pkg/tui/log/bubble.go @@ -1,10 +1,8 @@ package log import ( - "context" "fmt" "io" - "math" "strings" "time" @@ -13,13 +11,11 @@ import ( "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" - vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport" "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/dustin/go-humanize/english" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/object" + "github.com/charmbracelet/soft-serve/pkg/git" + "github.com/charmbracelet/soft-serve/pkg/tui/common" + "github.com/charmbracelet/soft-serve/pkg/tui/refs" + vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport" ) var ( @@ -30,7 +26,7 @@ var ( waitBeforeLoading = time.Millisecond * 300 ) -type commitMsg *object.Commit +type commitMsg *git.Commit type sessionState int @@ -42,13 +38,12 @@ const ( ) type item struct { - *object.Commit + *git.Commit } func (i item) Title() string { - lines := strings.Split(i.Message, "\n") - if len(lines) > 0 { - return lines[0] + if i.Commit != nil { + return strings.Split(i.Commit.Message, "\n")[0] } return "" } @@ -67,40 +62,45 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list if !ok { return } + if i.Commit == nil { + return + } + hash := i.ID.String() leftMargin := d.style.LogItemSelector.GetMarginLeft() + d.style.LogItemSelector.GetWidth() + d.style.LogItemHash.GetMarginLeft() + d.style.LogItemHash.GetWidth() + d.style.LogItemInactive.GetMarginLeft() - title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…") + title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…") if index == m.Index() { fmt.Fprint(w, d.style.LogItemSelector.Render(">")+ - d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+ + d.style.LogItemHash.Bold(true).Render(hash[:7])+ d.style.LogItemActive.Render(title)) } else { fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+ - d.style.LogItemHash.Render(i.Hash.String()[:7])+ + d.style.LogItemHash.Render(hash[:7])+ d.style.LogItemInactive.Render(title)) } } type Bubble struct { - repo types.Repo + repo common.GitRepo + count int64 list list.Model state sessionState commitViewport *vp.ViewportBubble - ref *plumbing.Reference + ref *git.Reference style *style.Styles width int widthMargin int height int heightMargin int - error types.ErrMsg + error common.ErrMsg spinner spinner.Model } -func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { +func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin) l.SetShowFilter(false) l.SetShowHelp(false) @@ -109,8 +109,8 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height l.SetShowTitle(false) l.SetFilteringEnabled(false) l.DisableQuitKeybindings() - l.KeyMap.NextPage = types.NextPage - l.KeyMap.PrevPage = types.PrevPage + l.KeyMap.NextPage = common.NextPage + l.KeyMap.PrevPage = common.PrevPage s := spinner.New() s.Spinner = spinner.Dot s.Style = styles.Spinner @@ -126,7 +126,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height height: height, heightMargin: heightMargin, list: l, - ref: repo.GetHEAD(), spinner: s, } b.SetSize(width, height) @@ -134,26 +133,49 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height } func (b *Bubble) reset() tea.Cmd { + errMsg := func(err error) tea.Cmd { + return func() tea.Msg { return common.ErrMsg{Err: err} } + } + ref, err := b.repo.HEAD() + if err != nil { + return errMsg(err) + } + b.ref = ref + count, err := b.repo.CountCommits(ref) + if err != nil { + return errMsg(err) + } + b.count = count b.state = logState b.list.Select(0) - cmd := b.updateItems() b.SetSize(b.width, b.height) + cmd := b.updateItems() return cmd } func (b *Bubble) updateItems() tea.Cmd { - items := make([]list.Item, 0) - cc, err := b.repo.GetCommits(b.ref) + count := b.count + items := make([]list.Item, count) + b.list.SetItems(items) + page := b.list.Paginator.Page + limit := b.list.Paginator.PerPage + skip := page * limit + cc, err := b.repo.CommitsByPage(b.ref, page+1, limit) if err != nil { - return func() tea.Msg { return types.ErrMsg{Err: err} } + return func() tea.Msg { return common.ErrMsg{Err: err} } } - for _, c := range cc { - items = append(items, item{c}) + for i, c := range cc { + idx := i + skip + if idx >= int(count) { + break + } + items[idx] = item{c} } - return b.list.SetItems(items) + cmd := b.list.SetItems(items) + return cmd } -func (b *Bubble) Help() []types.HelpEntry { +func (b *Bubble) Help() []common.HelpEntry { return nil } @@ -179,6 +201,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: b.SetSize(msg.Width, msg.Height) + cmds = append(cmds, b.updateItems()) case tea.KeyMsg: switch msg.String() { @@ -193,7 +216,22 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { b.state = logState } } - case types.ErrMsg: + switch b.state { + case logState: + curPage := b.list.Paginator.Page + m, cmd := b.list.Update(msg) + b.list = m + if m.Paginator.Page != curPage { + cmds = append(cmds, b.updateItems()) + } + cmds = append(cmds, cmd) + case commitState: + rv, cmd := b.commitViewport.Update(msg) + b.commitViewport = rv.(*vp.ViewportBubble) + cmds = append(cmds, cmd) + } + return b, tea.Batch(cmds...) + case common.ErrMsg: b.error = msg b.state = errorState return b, nil @@ -203,6 +241,11 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case refs.RefMsg: b.ref = msg + count, err := b.repo.CountCommits(msg) + if err != nil { + b.error = common.ErrMsg{Err: err} + } + b.count = count case spinner.TickMsg: if b.state == loadingState { s, cmd := b.spinner.Update(msg) @@ -213,42 +256,39 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - switch b.state { - case commitState: - rv, cmd := b.commitViewport.Update(msg) - b.commitViewport = rv.(*vp.ViewportBubble) - cmds = append(cmds, cmd) - case logState: - l, cmd := b.list.Update(msg) - b.list = l - cmds = append(cmds, cmd) - } - return b, tea.Batch(cmds...) } -func (b *Bubble) loadPatch(c *object.Commit) error { +func (b *Bubble) loadPatch(c *git.Commit) error { var patch strings.Builder style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize()) - ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait) - defer cancel() - p, err := b.repo.PatchCtx(ctx, c) + p, err := b.repo.Diff(c) if err != nil { return err } + stats := strings.Split(p.Stats().String(), "\n") + for i, l := range stats { + ch := strings.Split(l, "|") + if len(ch) > 1 { + adddel := ch[len(ch)-1] + adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+")) + adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-")) + stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel + } + } patch.WriteString(b.renderCommit(c)) - fpl := len(p.FilePatches()) - if fpl > types.MaxDiffFiles { - patch.WriteString("\n" + types.ErrDiffFilesTooLong.Error()) + fpl := len(p.Files) + if fpl > common.MaxDiffFiles { + patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error()) } else { - patch.WriteString("\n" + b.renderStats(p.Stats())) + patch.WriteString("\n" + strings.Join(stats, "\n")) } - if fpl <= types.MaxDiffFiles { - ps := p.String() - if len(strings.Split(ps, "\n")) > types.MaxDiffLines { - patch.WriteString("\n" + types.ErrDiffTooLong.Error()) + if fpl <= common.MaxDiffFiles { + ps := "" + if len(strings.Split(ps, "\n")) > common.MaxDiffLines { + patch.WriteString("\n" + common.ErrDiffTooLong.Error()) } else { - patch.WriteString("\n" + b.renderDiff(ps)) + patch.WriteString("\n" + b.renderDiff(p)) } } content := style.Render(patch.String()) @@ -280,118 +320,31 @@ func (b *Bubble) loadCommit() tea.Cmd { b.state = loadingState } if err != nil { - return types.ErrMsg{Err: err} + return common.ErrMsg{Err: err} } return commitMsg(c.Commit) } } -func (b *Bubble) renderCommit(c *object.Commit) string { +func (b *Bubble) renderCommit(c *git.Commit) string { s := strings.Builder{} // FIXME: lipgloss prints empty lines when CRLF is used // sanitize commit message from CRLF msg := strings.ReplaceAll(c.Message, "\r\n", "\n") s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", - b.style.LogCommitHash.Render("commit "+c.Hash.String()), - b.style.LogCommitAuthor.Render("Author: "+c.Author.String()), + b.style.LogCommitHash.Render("commit "+c.ID.String()), + b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)), b.style.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), b.style.LogCommitBody.Render(msg), )) return s.String() } -func (b *Bubble) renderStats(fileStats object.FileStats) string { - padLength := float64(len(" ")) - newlineLength := float64(len("\n")) - separatorLength := float64(len("|")) - // Soft line length limit. The text length calculation below excludes - // length of the change number. Adding that would take it closer to 80, - // but probably not more than 80, until it's a huge number. - lineLength := 72.0 - - // Get the longest filename and longest total change. - var longestLength float64 - var longestTotalChange float64 - for _, fs := range fileStats { - if int(longestLength) < len(fs.Name) { - longestLength = float64(len(fs.Name)) - } - totalChange := fs.Addition + fs.Deletion - if int(longestTotalChange) < totalChange { - longestTotalChange = float64(totalChange) - } - } - - // Parts of the output: - // |<+++/---> - // example: " main.go | 10 +++++++--- " - - // - leftTextLength := padLength + longestLength + padLength - - // <+++++/-----> - // Excluding number length here. - rightTextLength := padLength + padLength + newlineLength - - totalTextArea := leftTextLength + separatorLength + rightTextLength - heightOfHistogram := lineLength - totalTextArea - - // Scale the histogram. - var scaleFactor float64 - if longestTotalChange > heightOfHistogram { - // Scale down to heightOfHistogram. - scaleFactor = longestTotalChange / heightOfHistogram - } else { - scaleFactor = 1.0 - } - - taddc := 0 - tdelc := 0 - output := strings.Builder{} - for _, fs := range fileStats { - taddc += fs.Addition - tdelc += fs.Deletion - addn := float64(fs.Addition) - deln := float64(fs.Deletion) - addc := int(math.Floor(addn / scaleFactor)) - delc := int(math.Floor(deln / scaleFactor)) - if addc < 0 { - addc = 0 - } - if delc < 0 { - delc = 0 - } - adds := strings.Repeat("+", addc) - dels := strings.Repeat("-", delc) - diffLines := fmt.Sprint(fs.Addition + fs.Deletion) - totalDiffLines := fmt.Sprint(int(longestTotalChange)) - fmt.Fprintf(&output, "%s | %s %s%s\n", - fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)), - strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines, - b.style.LogCommitStatsAdd.Render(adds), - b.style.LogCommitStatsDel.Render(dels)) - } - files := len(fileStats) - fc := fmt.Sprintf("%s changed", english.Plural(files, "file", "")) - ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", "")) - dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", "")) - fmt.Fprint(&output, fc) - if taddc > 0 { - fmt.Fprintf(&output, ", %s", ins) - } - if tdelc > 0 { - fmt.Fprintf(&output, ", %s", dels) - } - fmt.Fprint(&output, "\n") - - return output.String() -} - -func (b *Bubble) renderDiff(diff string) string { +func (b *Bubble) renderDiff(diff *git.Diff) string { var s strings.Builder - pr := strings.Builder{} - diffChroma.Code = diff - err := diffChroma.Render(&pr, types.RenderCtx) + var pr strings.Builder + diffChroma.Code = diff.Patch() + err := diffChroma.Render(&pr, common.RenderCtx) if err != nil { s.WriteString(fmt.Sprintf("\n%s", err.Error())) } else { diff --git a/internal/tui/bubbles/git/refs/bubble.go b/pkg/tui/refs/bubble.go similarity index 79% rename from internal/tui/bubbles/git/refs/bubble.go rename to pkg/tui/refs/bubble.go index 0c99b4cbb7bd26a5d8f381de779285955c60f7e3..f31f12aaceb6c2855c93b886788f169411d96588 100644 --- a/internal/tui/bubbles/git/refs/bubble.go +++ b/pkg/tui/refs/bubble.go @@ -7,19 +7,19 @@ import ( "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/go-git/go-git/v5/plumbing" + "github.com/charmbracelet/soft-serve/pkg/git" + "github.com/charmbracelet/soft-serve/pkg/tui/common" ) -type RefMsg = *plumbing.Reference +type RefMsg = *git.Reference type item struct { - *plumbing.Reference + *git.Reference } func (i item) Short() string { - return i.Name().Short() + return i.Reference.Name().Short() } func (i item) FilterValue() string { return i.Short() } @@ -29,7 +29,7 @@ type items []item func (cl items) Len() int { return len(cl) } func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } func (cl items) Less(i, j int) bool { - return cl[i].Name().Short() < cl[j].Name().Short() + return cl[i].Short() < cl[j].Short() } type itemDelegate struct { @@ -47,7 +47,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } ref := i.Short() - if i.Name().IsTag() { + if i.Reference.IsTag() { ref = s.RefItemTag.Render(ref) } ref = s.RefItemBranch.Render(ref) @@ -55,7 +55,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list s.RefItemSelector.GetMarginLeft() - s.RefItemSelector.GetWidth() - s.RefItemInactive.GetMarginLeft() - ref = types.TruncateString(ref, refMaxWidth, "…") + ref = common.TruncateString(ref, refMaxWidth, "…") if index == m.Index() { fmt.Fprint(w, s.RefItemSelector.Render(">")+ s.RefItemActive.Render(ref)) @@ -66,17 +66,21 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } type Bubble struct { - repo types.Repo + repo common.GitRepo list list.Model style *style.Styles width int widthMargin int height int heightMargin int - ref *plumbing.Reference + ref *git.Reference } -func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { +func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { + head, err := repo.HEAD() + if err != nil { + return nil + } l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin) l.SetShowFilter(false) l.SetShowHelp(false) @@ -93,13 +97,13 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height widthMargin: widthMargin, heightMargin: heightMargin, list: l, - ref: repo.GetHEAD(), + ref: head, } b.SetSize(width, height) return b } -func (b *Bubble) SetBranch(ref *plumbing.Reference) (tea.Model, tea.Cmd) { +func (b *Bubble) SetBranch(ref *git.Reference) (tea.Model, tea.Cmd) { b.ref = ref return b, func() tea.Msg { return RefMsg(ref) @@ -123,21 +127,21 @@ func (b *Bubble) SetSize(width, height int) { b.list.Styles.PaginationStyle = b.style.RefPaginator.Copy().Width(width - b.widthMargin) } -func (b *Bubble) Help() []types.HelpEntry { +func (b *Bubble) Help() []common.HelpEntry { return nil } func (b *Bubble) updateItems() tea.Cmd { its := make(items, 0) tags := make(items, 0) - for _, r := range b.repo.GetReferences() { - if r.Type() != plumbing.HashReference { - continue - } - n := r.Name() - if n.IsTag() { + refs, err := b.repo.References() + if err != nil { + return func() tea.Msg { return common.ErrMsg{Err: err} } + } + for _, r := range refs { + if r.IsTag() { tags = append(tags, item{r}) - } else if n.IsBranch() { + } else if r.IsBranch() { its = append(its, item{r}) } } diff --git a/internal/tui/bubbles/git/tree/bubble.go b/pkg/tui/tree/bubble.go similarity index 73% rename from internal/tui/bubbles/git/tree/bubble.go rename to pkg/tui/tree/bubble.go index e6c2a22d4698cb964352b1468a33569a80b7b775..c0e735c2f7f375c9aeb77daeb04198ebd4c107b0 100644 --- a/internal/tui/bubbles/git/tree/bubble.go +++ b/pkg/tui/tree/bubble.go @@ -3,22 +3,20 @@ package tree import ( "fmt" "io" + "io/fs" "path/filepath" - "sort" "strings" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" - vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport" "github.com/charmbracelet/soft-serve/internal/tui/style" + "github.com/charmbracelet/soft-serve/pkg/git" + "github.com/charmbracelet/soft-serve/pkg/tui/common" + "github.com/charmbracelet/soft-serve/pkg/tui/refs" + vp "github.com/charmbracelet/soft-serve/pkg/tui/viewport" "github.com/dustin/go-humanize" - "github.com/go-git/go-git/v5/plumbing" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" ) type fileMsg struct { @@ -34,16 +32,15 @@ const ( ) type item struct { - entry *object.TreeEntry - file *object.File + entry *git.TreeEntry } func (i item) Name() string { - return i.entry.Name + return i.entry.Name() } -func (i item) Mode() filemode.FileMode { - return i.entry.Mode +func (i item) Mode() fs.FileMode { + return i.entry.Mode() } func (i item) FilterValue() string { return i.Name() } @@ -53,11 +50,11 @@ type items []item func (cl items) Len() int { return len(cl) } func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } func (cl items) Less(i, j int) bool { - if cl[i].Mode() == filemode.Dir && cl[j].Mode() == filemode.Dir { + if cl[i].entry.IsTree() && cl[j].entry.IsTree() { return cl[i].Name() < cl[j].Name() - } else if cl[i].Mode() == filemode.Dir { + } else if cl[i].entry.IsTree() { return true - } else if cl[j].Mode() == filemode.Dir { + } else if cl[j].entry.IsTree() { return false } else { return cl[i].Name() < cl[j].Name() @@ -79,15 +76,13 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } name := i.Name() - if i.Mode() == filemode.Dir { + size := humanize.Bytes(uint64(i.entry.Size())) + if i.entry.IsTree() { + size = "" name = s.TreeFileDir.Render(name) } - size := "" - if i.file != nil { - size = humanize.Bytes(uint64(i.file.Size)) - } var cs lipgloss.Style - mode, _ := i.Mode().ToOSFileMode() + mode := i.Mode() if index == m.Index() { cs = s.TreeItemActive fmt.Fprint(w, s.TreeItemSelector.Render(">")) @@ -101,7 +96,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list s.TreeFileMode.GetWidth() + cs.GetMarginLeft() rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size) - name = types.TruncateString(name, m.Width()-leftMargin-rightMargin, "…") + name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…") sizeStyle := s.TreeFileSize.Copy(). Width(m.Width() - leftMargin - @@ -114,7 +109,7 @@ func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list } type Bubble struct { - repo types.Repo + repo common.GitRepo list list.Model style *style.Styles width int @@ -123,13 +118,13 @@ type Bubble struct { heightMargin int path string state sessionState - error types.ErrMsg + error common.ErrMsg fileViewport *vp.ViewportBubble lastSelected []int - ref *plumbing.Reference + ref *git.Reference } -func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { +func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin) l.SetShowFilter(false) l.SetShowHelp(false) @@ -138,8 +133,8 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height l.SetShowTitle(false) l.SetFilteringEnabled(false) l.DisableQuitKeybindings() - l.KeyMap.NextPage = types.NextPage - l.KeyMap.PrevPage = types.PrevPage + l.KeyMap.NextPage = common.NextPage + l.KeyMap.PrevPage = common.PrevPage l.Styles.NoItems = styles.TreeNoItems b := &Bubble{ fileViewport: &vp.ViewportBubble{ @@ -153,7 +148,6 @@ func NewBubble(repo types.Repo, styles *style.Styles, width, widthMargin, height heightMargin: heightMargin, list: l, state: treeState, - ref: repo.GetHEAD(), } b.SetSize(width, height) return b @@ -169,6 +163,13 @@ func (b *Bubble) reset() tea.Cmd { } func (b *Bubble) Init() tea.Cmd { + head, err := b.repo.HEAD() + if err != nil { + return func() tea.Msg { + return common.ErrMsg{Err: err} + } + } + b.ref = head return nil } @@ -181,37 +182,30 @@ func (b *Bubble) SetSize(width, height int) { b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin) } -func (b *Bubble) Help() []types.HelpEntry { +func (b *Bubble) Help() []common.HelpEntry { return nil } func (b *Bubble) updateItems() tea.Cmd { - its := make(items, 0) + files := make([]list.Item, 0) + dirs := make([]list.Item, 0) t, err := b.repo.Tree(b.ref, b.path) if err != nil { - return func() tea.Msg { return types.ErrMsg{Err: err} } + return func() tea.Msg { return common.ErrMsg{Err: err} } } - tw := object.NewTreeWalker(t, false, map[plumbing.Hash]bool{}) - defer tw.Close() - for { - _, e, err := tw.Next() - if err != nil { - break - } - i := item{entry: &e} - if e.Mode.IsFile() { - if f, err := t.TreeEntryFile(&e); err == nil { - i.file = f - } - } - its = append(its, i) + ents, err := t.Entries() + if err != nil { + return func() tea.Msg { return common.ErrMsg{Err: err} } } - sort.Sort(its) - itt := make([]list.Item, len(its)) - for i, it := range its { - itt[i] = it + ents.Sort() + for _, e := range ents { + if e.IsTree() { + dirs = append(dirs, item{e}) + } else { + files = append(files, item{e}) + } } - cmd := b.list.SetItems(itt) + cmd := b.list.SetItems(append(dirs, files...)) b.list.Select(0) return cmd } @@ -224,7 +218,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: if b.state == errorState { - ref := b.repo.GetHEAD() + ref, _ := b.repo.HEAD() b.ref = ref return b, tea.Batch(b.reset(), func() tea.Msg { return ref @@ -240,7 +234,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { item := b.list.SelectedItem().(item) mode := item.Mode() b.path = filepath.Join(b.path, item.Name()) - if mode == filemode.Dir { + if mode.IsDir() { b.lastSelected = append(b.lastSelected, index) cmds = append(cmds, b.updateItems()) } else { @@ -267,7 +261,7 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { b.ref = msg return b, b.reset() - case types.ErrMsg: + case common.ErrMsg: b.error = msg b.state = errorState return b, nil @@ -308,22 +302,23 @@ func (b *Bubble) View() string { func (b *Bubble) loadFile(i item) tea.Cmd { return func() tea.Msg { - if !i.Mode().IsFile() || i.file == nil { - return types.ErrMsg{Err: types.ErrInvalidFile} + f := i.entry.File() + if i.Mode().IsDir() || f == nil { + return common.ErrMsg{Err: common.ErrInvalidFile} } - bin, err := i.file.IsBinary() + bin, err := f.IsBinary() if err != nil { - return types.ErrMsg{Err: err} + return common.ErrMsg{Err: err} } if bin { - return types.ErrMsg{Err: types.ErrBinaryFile} + return common.ErrMsg{Err: common.ErrBinaryFile} } - c, err := i.file.Contents() + c, err := f.Bytes() if err != nil { - return types.ErrMsg{Err: err} + return common.ErrMsg{Err: err} } return fileMsg{ - content: c, + content: string(c), } } } @@ -331,11 +326,11 @@ func (b *Bubble) loadFile(i item) tea.Cmd { func (b *Bubble) renderFile(m fileMsg) string { s := strings.Builder{} c := m.content - if len(strings.Split(c, "\n")) > types.MaxDiffLines { - s.WriteString(types.ErrFileTooLarge.Error()) + if len(strings.Split(c, "\n")) > common.MaxDiffLines { + s.WriteString(b.style.TreeNoItems.Render(common.ErrFileTooLarge.Error())) } else { w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize() - f, err := types.RenderFile(b.path, m.content, w) + f, err := common.RenderFile(b.path, m.content, w) if err != nil { s.WriteString(err.Error()) } else { diff --git a/internal/tui/bubbles/git/viewport/viewport_patch.go b/pkg/tui/viewport/viewport_patch.go similarity index 100% rename from internal/tui/bubbles/git/viewport/viewport_patch.go rename to pkg/tui/viewport/viewport_patch.go diff --git a/server/middleware.go b/server/middleware.go index e8b86b8c568cf6ac28bf923cd6630ef2cb76174d..a87ae492deaebd1b22e8152109fb5275aae4dbe7 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -3,19 +3,18 @@ package server import ( "fmt" "path/filepath" - "sort" "strings" "github.com/alecthomas/chroma/lexers" gansi "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" appCfg "github.com/charmbracelet/soft-serve/internal/config" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types" + "github.com/charmbracelet/soft-serve/pkg/git" + "github.com/charmbracelet/soft-serve/pkg/tui/common" "github.com/charmbracelet/wish" gitwish "github.com/charmbracelet/wish/git" "github.com/gliderlabs/ssh" - "github.com/go-git/go-git/v5/plumbing/filemode" - "github.com/go-git/go-git/v5/plumbing/object" + ggit "github.com/gogs/git-module" "github.com/muesli/termenv" ) @@ -27,22 +26,6 @@ var ( filemodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777")) ) -type entries []object.TreeEntry - -func (cl entries) Len() int { return len(cl) } -func (cl entries) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } -func (cl entries) Less(i, j int) bool { - if cl[i].Mode == filemode.Dir && cl[j].Mode == filemode.Dir { - return cl[i].Name < cl[j].Name - } else if cl[i].Mode == filemode.Dir { - return true - } else if cl[j].Mode == filemode.Dir { - return false - } else { - return cl[i].Name < cl[j].Name - } -} - // softServeMiddleware is a middleware that handles displaying files with the // option of syntax highlighting and line numbers. func softServeMiddleware(ac *appCfg.Config) wish.Middleware { @@ -90,15 +73,27 @@ func softServeMiddleware(ac *appCfg.Config) wish.Middleware { _ = s.Exit(1) return } + ref, err := rs.HEAD() + if err != nil { + _, _ = s.Write([]byte(err.Error())) + _ = s.Exit(1) + return + } p := strings.Join(ps[1:], "/") - t, err := rs.LatestTree(p) - if err != nil && err != object.ErrDirectoryNotFound { + t, err := rs.Tree(ref, p) + if err != nil && err != ggit.ErrRevisionNotExist { _, _ = s.Write([]byte(err.Error())) _ = s.Exit(1) return } - if err == object.ErrDirectoryNotFound { - fc, err := rs.LatestFile(p) + if err == ggit.ErrRevisionNotExist { + _, _ = s.Write([]byte(git.ErrFileNotFound.Error())) + _ = s.Exit(1) + return + } + ents, err := t.Entries() + if err != nil { + fc, _, err := rs.LatestFile(p) if err != nil { _, _ = s.Write([]byte(err.Error())) _ = s.Exit(1) @@ -118,20 +113,19 @@ func softServeMiddleware(ac *appCfg.Config) wish.Middleware { } s.Write([]byte(fc)) } else { - ents := entries(t.Entries) - sort.Sort(ents) + ents.Sort() for _, e := range ents { - m, _ := e.Mode.ToOSFileMode() + m := e.Mode() if m == 0 { s.Write([]byte(strings.Repeat(" ", 10))) } else { s.Write([]byte(filemodeStyle.Render(m.String()))) } s.Write([]byte(" ")) - if e.Mode.IsFile() { - s.Write([]byte(filenameStyle.Render(e.Name))) + if !e.IsTree() { + s.Write([]byte(filenameStyle.Render(e.Name()))) } else { - s.Write([]byte(dirnameStyle.Render(e.Name))) + s.Write([]byte(dirnameStyle.Render(e.Name()))) } s.Write([]byte("\n")) } @@ -177,7 +171,7 @@ func withFormatting(p, c string) (string, error) { Language: lang, } r := strings.Builder{} - styles := types.DefaultStyles() + styles := common.DefaultStyles() styles.CodeBlock.Margin = &zero rctx := gansi.NewRenderContext(gansi.Options{ Styles: styles, diff --git a/server/server.go b/server/server.go index 7b72f6ca3705571f3c31248994dbde0e91a636b8..3f6a56ccbfe198da90c8985c25c6c3938729465c 100644 --- a/server/server.go +++ b/server/server.go @@ -8,7 +8,6 @@ import ( "github.com/charmbracelet/soft-serve/config" appCfg "github.com/charmbracelet/soft-serve/internal/config" "github.com/charmbracelet/soft-serve/internal/tui" - "github.com/charmbracelet/wish" bm "github.com/charmbracelet/wish/bubbletea" gm "github.com/charmbracelet/wish/git"