From 7f9041569d498d92f98591d3e7386382740a954e Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Thu, 21 Apr 2022 23:03:13 -0400 Subject: [PATCH] wip: initial repo ui implementation --- config/auth.go | 95 ++++++ config/config.go | 23 +- config/git.go | 310 ++++++++++++++---- server/cmd/cat.go | 4 +- server/cmd/git.go | 4 +- ui/components/code/code.go | 17 +- ui/components/statusbar/statusbar.go | 61 ++++ ui/components/tabs/tabs.go | 70 ++++ .../{viewport_patch.go => viewport.go} | 20 +- ui/git.go | 25 ++ ui/git/git.go | 25 ++ ui/keymap/keymap.go | 11 + ui/pages/repo/log.go | 57 ++++ ui/pages/repo/logitem.go | 73 +++++ ui/pages/repo/repo.go | 185 +++++++++++ ui/styles/styles.go | 47 ++- ui/ui.go | 14 +- 17 files changed, 930 insertions(+), 111 deletions(-) create mode 100644 config/auth.go create mode 100644 ui/components/statusbar/statusbar.go create mode 100644 ui/components/tabs/tabs.go rename ui/components/viewport/{viewport_patch.go => viewport.go} (55%) create mode 100644 ui/git.go create mode 100644 ui/git/git.go create mode 100644 ui/pages/repo/log.go create mode 100644 ui/pages/repo/logitem.go create mode 100644 ui/pages/repo/repo.go diff --git a/config/auth.go b/config/auth.go new file mode 100644 index 0000000000000000000000000000000000000000..54eb0df6bd291aebe75e16cb63fd86241d008187 --- /dev/null +++ b/config/auth.go @@ -0,0 +1,95 @@ +package config + +import ( + "log" + "strings" + + gm "github.com/charmbracelet/wish/git" + "github.com/gliderlabs/ssh" +) + +// Push registers Git push functionality for the given repo and key. +func (cfg *Config) Push(repo string, pk ssh.PublicKey) { + go func() { + err := cfg.Reload() + if err != nil { + log.Printf("error reloading after push: %s", err) + } + if cfg.Cfg.Callbacks != nil { + cfg.Cfg.Callbacks.Push(repo) + } + r, err := cfg.Source.GetRepo(repo) + if err != nil { + log.Printf("error getting repo after push: %s", err) + return + } + err = r.UpdateServerInfo() + if err != nil { + log.Printf("error updating server info after push: %s", err) + } + }() +} + +// Fetch registers Git fetch functionality for the given repo and key. +func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) { + if cfg.Cfg.Callbacks != nil { + cfg.Cfg.Callbacks.Fetch(repo) + } +} + +// AuthRepo grants repo authorization to the given key. +func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel { + return cfg.accessForKey(repo, pk) +} + +// PasswordHandler returns whether or not password access is allowed. +func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool { + return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless +} + +// PublicKeyHandler returns whether or not the given public key may access the +// repo. +func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { + return cfg.accessForKey("", pk) != gm.NoAccess +} + +func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel { + private := cfg.isPrivate(repo) + for _, u := range cfg.Users { + for _, k := range u.PublicKeys { + apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k))) + if err != nil { + log.Printf("error: malformed authorized key: '%s'", k) + return gm.NoAccess + } + if ssh.KeysEqual(pk, apk) { + if u.Admin { + return gm.AdminAccess + } + for _, r := range u.CollabRepos { + if repo == r { + return gm.ReadWriteAccess + } + } + if !private { + return gm.ReadOnlyAccess + } + } + } + } + if private && len(cfg.Users) > 0 { + return gm.NoAccess + } + switch cfg.AnonAccess { + case "no-access": + return gm.NoAccess + case "read-only": + return gm.ReadOnlyAccess + case "read-write": + return gm.ReadWriteAccess + case "admin-access": + return gm.AdminAccess + default: + return gm.NoAccess + } +} diff --git a/config/config.go b/config/config.go index e84e1c3c0ff81e8486597f068f99ea0239b10d59..1e5f5621fe10cfda79fc36f8122ee53d973b9332 100644 --- a/config/config.go +++ b/config/config.go @@ -17,7 +17,6 @@ import ( "fmt" "os" - "github.com/charmbracelet/soft-serve/internal/git" "github.com/charmbracelet/soft-serve/server/config" "github.com/go-git/go-billy/v5/memfs" ggit "github.com/go-git/go-git/v5" @@ -28,15 +27,15 @@ import ( // Config is the Soft Serve configuration. type Config struct { - Name string `yaml:"name"` - Host string `yaml:"host"` - Port int `yaml:"port"` - AnonAccess string `yaml:"anon-access"` - AllowKeyless bool `yaml:"allow-keyless"` - Users []User `yaml:"users"` - Repos []Repo `yaml:"repos"` - Source *git.RepoSource `yaml:"-"` - Cfg *config.Config `yaml:"-"` + Name string `yaml:"name"` + Host string `yaml:"host"` + Port int `yaml:"port"` + AnonAccess string `yaml:"anon-access"` + AllowKeyless bool `yaml:"allow-keyless"` + Users []User `yaml:"users"` + Repos []MenuRepo `yaml:"repos"` + Source *RepoSource `yaml:"-"` + Cfg *config.Config `yaml:"-"` mtx sync.Mutex } @@ -49,7 +48,7 @@ type User struct { } // Repo contains repository configuration information. -type Repo struct { +type MenuRepo struct { Name string `yaml:"name"` Repo string `yaml:"repo"` Note string `yaml:"note"` @@ -82,7 +81,7 @@ func NewConfig(cfg *config.Config) (*Config, error) { pks = append(pks, pk) } - rs := git.NewRepoSource(cfg.RepoPath) + rs := NewRepoSource(cfg.RepoPath) c := &Config{ Cfg: cfg, } diff --git a/config/git.go b/config/git.go index 54eb0df6bd291aebe75e16cb63fd86241d008187..14412ba76deaa128f4da3b8035845beca4c26405 100644 --- a/config/git.go +++ b/config/git.go @@ -1,95 +1,261 @@ package config import ( + "errors" "log" - "strings" + "os" + "path/filepath" + "sync" - gm "github.com/charmbracelet/wish/git" - "github.com/gliderlabs/ssh" + "github.com/charmbracelet/soft-serve/git" + "github.com/gobwas/glob" + "github.com/golang/groupcache/lru" ) -// Push registers Git push functionality for the given repo and key. -func (cfg *Config) Push(repo string, pk ssh.PublicKey) { - go func() { - err := cfg.Reload() - if err != nil { - log.Printf("error reloading after push: %s", err) - } - if cfg.Cfg.Callbacks != nil { - cfg.Cfg.Callbacks.Push(repo) - } - r, err := cfg.Source.GetRepo(repo) - if err != nil { - log.Printf("error getting repo after push: %s", err) - return - } - err = r.UpdateServerInfo() - if err != nil { - log.Printf("error updating server info after push: %s", err) - } - }() +// ErrMissingRepo indicates that the requested repository could not be found. +var ErrMissingRepo = errors.New("missing repo") + +// Repo represents a Git repository. +type Repo struct { + path string + repository *git.Repository + readme string + readmePath string + head *git.Reference + refs []*git.Reference + patchCache *lru.Cache +} + +// open opens a Git repository. +func (rs *RepoSource) open(path string) (*Repo, error) { + rg, err := git.Open(path) + if err != nil { + return nil, err + } + r := &Repo{ + path: path, + repository: rg, + patchCache: lru.New(1000), + } + _, err = r.HEAD() + if err != nil { + return nil, err + } + _, err = r.References() + if err != nil { + return nil, err + } + return r, nil +} + +// Path returns the path to the repository. +func (r *Repo) Path() string { + return r.path +} + +// GetName returns the name of the repository. +func (r *Repo) Name() string { + return filepath.Base(r.path) +} + +// 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 +} + +// 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 + } + 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 + } + refs, err := r.repository.References() + if err != nil { + return nil, err + } + 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 + } + r.patchCache.Add(hash, diff) + return diff, nil } -// Fetch registers Git fetch functionality for the given repo and key. -func (cfg *Config) Fetch(repo string, pk ssh.PublicKey) { - if cfg.Cfg.Callbacks != nil { - cfg.Cfg.Callbacks.Fetch(repo) +// 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 tc, nil +} + +// 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) } -// AuthRepo grants repo authorization to the given key. -func (cfg *Config) AuthRepo(repo string, pk ssh.PublicKey) gm.AccessLevel { - return cfg.accessForKey(repo, pk) +// Push pushes the repository to the remote. +func (r *Repo) Push(remote, branch string) error { + return r.repository.Push(remote, branch) } -// PasswordHandler returns whether or not password access is allowed. -func (cfg *Config) PasswordHandler(ctx ssh.Context, password string) bool { - return (cfg.AnonAccess != "no-access") && cfg.AllowKeyless +// RepoSource is a reference to an on-disk repositories. +type RepoSource struct { + Path string + mtx sync.Mutex + repos map[string]*Repo } -// PublicKeyHandler returns whether or not the given public key may access the -// repo. -func (cfg *Config) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) bool { - return cfg.accessForKey("", pk) != gm.NoAccess +// NewRepoSource creates a new RepoSource. +func NewRepoSource(repoPath string) *RepoSource { + err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700)) + if err != nil { + log.Fatal(err) + } + rs := &RepoSource{Path: repoPath} + rs.repos = make(map[string]*Repo, 0) + return rs +} + +// AllRepos returns all repositories for the given RepoSource. +func (rs *RepoSource) AllRepos() []*Repo { + rs.mtx.Lock() + defer rs.mtx.Unlock() + repos := make([]*Repo, 0, len(rs.repos)) + for _, r := range rs.repos { + repos = append(repos, r) + } + return repos } -func (cfg *Config) accessForKey(repo string, pk ssh.PublicKey) gm.AccessLevel { - private := cfg.isPrivate(repo) - for _, u := range cfg.Users { - for _, k := range u.PublicKeys { - apk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(strings.TrimSpace(k))) +// GetRepo returns a repository by name. +func (rs *RepoSource) GetRepo(name string) (*Repo, error) { + rs.mtx.Lock() + defer rs.mtx.Unlock() + r, ok := rs.repos[name] + if !ok { + return nil, ErrMissingRepo + } + return r, nil +} + +// InitRepo initializes a new Git repository. +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.Init(rp, bare) + if err != nil { + return nil, err + } + r := &Repo{ + path: rp, + repository: rg, + refs: []*git.Reference{ + git.NewReference(rp, git.RefsHeads+"master"), + }, + } + rs.repos[name] = r + return r, nil +} + +// 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 { + log.Printf("error opening repository %s: %s", name, err) + 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 + } + for _, de := range rd { + err = rs.LoadRepo(de.Name()) + if err == git.ErrNotAGitRepository { + continue + } + if err != nil { + return err + } + } + return nil +} + +// LatestFile returns the contents of the latest file at the specified path in +// the repository and its file path. +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 "", "", err + } + ents, err := t.Entries() + if err != nil { + return "", "", err + } + for _, e := range ents { + fp := filepath.Join(dir, e.Name()) + if e.IsTree() { + continue + } + if g.Match(fp) { + bts, err := e.Contents() if err != nil { - log.Printf("error: malformed authorized key: '%s'", k) - return gm.NoAccess - } - if ssh.KeysEqual(pk, apk) { - if u.Admin { - return gm.AdminAccess - } - for _, r := range u.CollabRepos { - if repo == r { - return gm.ReadWriteAccess - } - } - if !private { - return gm.ReadOnlyAccess - } + return "", "", err } + return string(bts), fp, nil } } - if private && len(cfg.Users) > 0 { - return gm.NoAccess - } - switch cfg.AnonAccess { - case "no-access": - return gm.NoAccess - case "read-only": - return gm.ReadOnlyAccess - case "read-write": - return gm.ReadWriteAccess - case "admin-access": - return gm.AdminAccess - default: - return gm.NoAccess - } + return "", "", git.ErrFileNotFound +} + +// UpdateServerInfo updates the server info for the repository. +func (r *Repo) UpdateServerInfo() error { + return r.repository.UpdateServerInfo() } diff --git a/server/cmd/cat.go b/server/cmd/cat.go index 18ff5678c982d9e4b73cab235c331c9d57d78d3b..c5c62707479974d4cf420c61b35b5c047439b808 100644 --- a/server/cmd/cat.go +++ b/server/cmd/cat.go @@ -7,7 +7,7 @@ import ( "github.com/alecthomas/chroma/lexers" gansi "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/git" + "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/tui/common" gitwish "github.com/charmbracelet/wish/git" "github.com/muesli/termenv" @@ -40,7 +40,7 @@ func CatCommand() *cobra.Command { if auth < gitwish.ReadOnlyAccess { return ErrUnauthorized } - var repo *git.Repo + var repo *config.Repo repoExists := false for _, rp := range ac.Source.AllRepos() { if rp.Name() == rn { diff --git a/server/cmd/git.go b/server/cmd/git.go index fd2c3be87505141eb56f3c2dd625fcd43097c84f..ca8095ac5082c9a0e83eb42b6c4d883082250b4f 100644 --- a/server/cmd/git.go +++ b/server/cmd/git.go @@ -4,7 +4,7 @@ import ( "io" "os/exec" - "github.com/charmbracelet/soft-serve/internal/git" + "github.com/charmbracelet/soft-serve/config" gitwish "github.com/charmbracelet/wish/git" "github.com/spf13/cobra" ) @@ -23,7 +23,7 @@ func GitCommand() *cobra.Command { if len(args) < 1 { return runGit(nil, s, s, "") } - var repo *git.Repo + var repo *config.Repo rn := args[0] repoExists := false for _, rp := range ac.Source.AllRepos() { diff --git a/ui/components/code/code.go b/ui/components/code/code.go index f52b213bd8dbd84a83783ac90d4a6caf2b07a234..cf0bf45612d7eb72d36e53bb1519fc06c47e96a6 100644 --- a/ui/components/code/code.go +++ b/ui/components/code/code.go @@ -4,7 +4,6 @@ import ( "strings" "github.com/alecthomas/chroma/lexers" - "github.com/charmbracelet/bubbles/viewport" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/glamour" gansi "github.com/charmbracelet/glamour/ansi" @@ -20,21 +19,17 @@ type Code struct { common common.Common content string extension string - viewport *vp.ViewportBubble + viewport *vp.Viewport NoContentStyle lipgloss.Style } // New returns a new Code. func New(c common.Common, content, extension string) *Code { r := &Code{ - common: c, - content: content, - extension: extension, - viewport: &vp.ViewportBubble{ - Viewport: &viewport.Model{ - MouseWheelEnabled: true, - }, - }, + common: c, + content: content, + extension: extension, + viewport: vp.New(), NoContentStyle: c.Styles.CodeNoContent.Copy(), } r.SetSize(c.Width, c.Height) @@ -89,7 +84,7 @@ func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, r.Init()) } v, cmd := r.viewport.Update(msg) - r.viewport = v.(*vp.ViewportBubble) + r.viewport = v.(*vp.Viewport) if cmd != nil { cmds = append(cmds, cmd) } diff --git a/ui/components/statusbar/statusbar.go b/ui/components/statusbar/statusbar.go new file mode 100644 index 0000000000000000000000000000000000000000..dce54deffb0f7e46c241d083ebb03ee4f93dbe7a --- /dev/null +++ b/ui/components/statusbar/statusbar.go @@ -0,0 +1,61 @@ +package statusbar + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/ui/common" +) + +type StatusBarMsg struct { + Key string + Value string + Info string + Branch string +} + +type StatusBar struct { + common common.Common + msg StatusBarMsg +} + +func New(c common.Common) *StatusBar { + s := &StatusBar{ + common: c, + } + return s +} + +func (s *StatusBar) SetSize(width, height int) { + s.common.Width = width + s.common.Height = height +} + +func (s *StatusBar) Init() tea.Cmd { + return nil +} + +func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case StatusBarMsg: + s.msg = msg + } + return s, nil +} + +func (s *StatusBar) View() string { + st := s.common.Styles + w := lipgloss.Width + key := st.StatusBarKey.Render(s.msg.Key) + info := st.StatusBarInfo.Render(s.msg.Info) + branch := st.StatusBarBranch.Render(s.msg.Branch) + value := st.StatusBarValue. + Width(s.common.Width - w(key) - w(info) - w(branch)). + Render(s.msg.Value) + + return lipgloss.JoinHorizontal(lipgloss.Top, + key, + value, + info, + branch, + ) +} diff --git a/ui/components/tabs/tabs.go b/ui/components/tabs/tabs.go new file mode 100644 index 0000000000000000000000000000000000000000..ae4bd3857cb069ed1b2210844085833a0e175329 --- /dev/null +++ b/ui/components/tabs/tabs.go @@ -0,0 +1,70 @@ +package tabs + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/ui/common" +) + +type ActiveTabMsg int + +type Tabs struct { + common common.Common + tabs []string + activeTab int +} + +func New(c common.Common, tabs []string) *Tabs { + r := &Tabs{ + common: c, + tabs: tabs, + activeTab: 0, + } + return r +} + +func (t *Tabs) SetSize(width, height int) { + t.common.SetSize(width, height) +} + +func (t *Tabs) Init() tea.Cmd { + t.activeTab = 0 + return nil +} + +func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab": + t.activeTab = (t.activeTab + 1) % len(t.tabs) + cmds = append(cmds, t.activeTabCmd) + case "shift+tab": + t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs) + cmds = append(cmds, t.activeTabCmd) + } + } + return t, tea.Batch(cmds...) +} + +func (t *Tabs) View() string { + s := strings.Builder{} + sep := t.common.Styles.TabSeparator + for i, tab := range t.tabs { + style := t.common.Styles.Tab.Copy() + if i == t.activeTab { + style = t.common.Styles.TabActive.Copy() + } + s.WriteString(style.Render(tab)) + if i != len(t.tabs)-1 { + s.WriteString(sep.String()) + } + } + return s.String() +} + +func (t *Tabs) activeTabCmd() tea.Msg { + return ActiveTabMsg(t.activeTab) +} diff --git a/ui/components/viewport/viewport_patch.go b/ui/components/viewport/viewport.go similarity index 55% rename from ui/components/viewport/viewport_patch.go rename to ui/components/viewport/viewport.go index c60fec050174887fb4a276a3444b01b06df805df..6de1359726c7c5dd707c5f2885098bdebbb2dad0 100644 --- a/ui/components/viewport/viewport_patch.go +++ b/ui/components/viewport/viewport.go @@ -5,30 +5,38 @@ import ( tea "github.com/charmbracelet/bubbletea" ) -// ViewportBubble represents a viewport component. -type ViewportBubble struct { +// Viewport represents a viewport component. +type Viewport struct { Viewport *viewport.Model } +func New() *Viewport { + return &Viewport{ + Viewport: &viewport.Model{ + MouseWheelEnabled: true, + }, + } +} + // SetSize implements common.Component. -func (v *ViewportBubble) SetSize(width, height int) { +func (v *Viewport) SetSize(width, height int) { v.Viewport.Width = width v.Viewport.Height = height } // Init implements tea.Model. -func (v *ViewportBubble) Init() tea.Cmd { +func (v *Viewport) Init() tea.Cmd { return nil } // Update implements tea.Model. -func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (v *Viewport) Update(msg tea.Msg) (tea.Model, tea.Cmd) { vp, cmd := v.Viewport.Update(msg) v.Viewport = &vp return v, cmd } // View implements tea.Model. -func (v *ViewportBubble) View() string { +func (v *Viewport) View() string { return v.Viewport.View() } diff --git a/ui/git.go b/ui/git.go new file mode 100644 index 0000000000000000000000000000000000000000..e4dcf80a2453d0f946ae09ad5dbd7ba71eb53571 --- /dev/null +++ b/ui/git.go @@ -0,0 +1,25 @@ +package ui + +import ( + "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/ui/git" +) + +// source is a wrapper around config.RepoSource that implements git.GitRepoSource. +type source struct { + *config.RepoSource +} + +// GetRepo implements git.GitRepoSource. +func (s *source) GetRepo(name string) (git.GitRepo, error) { + return s.RepoSource.GetRepo(name) +} + +// AllRepos implements git.GitRepoSource. +func (s *source) AllRepos() []git.GitRepo { + rs := make([]git.GitRepo, 0) + for _, r := range s.RepoSource.AllRepos() { + rs = append(rs, r) + } + return rs +} diff --git a/ui/git/git.go b/ui/git/git.go new file mode 100644 index 0000000000000000000000000000000000000000..578544fb1e31ee7cad99427bea349f8cfc21e23c --- /dev/null +++ b/ui/git/git.go @@ -0,0 +1,25 @@ +package git + +import ( + "errors" + + "github.com/charmbracelet/soft-serve/git" +) + +var ErrMissingRepo = errors.New("missing repo") + +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) +} + +type GitRepoSource interface { + GetRepo(string) (GitRepo, error) + AllRepos() []GitRepo +} diff --git a/ui/keymap/keymap.go b/ui/keymap/keymap.go index e347a8bd5456d4927485c82bfa9afe724f4c14b6..9a3cac2b7dbd4c5942eecc7b687b471db8e55d4c 100644 --- a/ui/keymap/keymap.go +++ b/ui/keymap/keymap.go @@ -12,6 +12,7 @@ type KeyMap struct { Arrows key.Binding Select key.Binding Section key.Binding + Back key.Binding } // DefaultKeyMap returns the default key map. @@ -115,5 +116,15 @@ func DefaultKeyMap() *KeyMap { ), ) + km.Back = key.NewBinding( + key.WithKeys( + "esc", + ), + key.WithHelp( + "esc", + "back", + ), + ) + return km } diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go new file mode 100644 index 0000000000000000000000000000000000000000..25d9522f54f802ab67b2378bfd29448f87e794cf --- /dev/null +++ b/ui/pages/repo/log.go @@ -0,0 +1,57 @@ +package repo + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/components/viewport" +) + +type view int + +const ( + logView view = iota + commitView +) + +type Log struct { + common common.Common + selector *selector.Selector + vp *viewport.Viewport + activeView view +} + +func NewLog(common common.Common) *Log { + l := &Log{ + common: common, + selector: selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{common.Styles}), + vp: viewport.New(), + activeView: logView, + } + return l +} + +func (l *Log) SetSize(width, height int) { + l.common.SetSize(width, height) + l.selector.SetSize(width, height) + l.vp.SetSize(width, height) +} + +func (l *Log) Init() tea.Cmd { + return nil +} + +func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return l, nil +} + +func (l *Log) View() string { + switch l.activeView { + case logView: + return l.selector.View() + case commitView: + return l.vp.View() + default: + return "" + } +} diff --git a/ui/pages/repo/logitem.go b/ui/pages/repo/logitem.go new file mode 100644 index 0000000000000000000000000000000000000000..b79cf572c137995c9568355f43d174862b002e1c --- /dev/null +++ b/ui/pages/repo/logitem.go @@ -0,0 +1,73 @@ +package repo + +import ( + "fmt" + "io" + "strings" + + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/styles" + "github.com/muesli/reflow/truncate" +) + +type LogItem struct { + *git.Commit +} + +func (i LogItem) ID() string { + return i.Commit.ID.String() +} + +func (i LogItem) Title() string { + if i.Commit != nil { + return strings.Split(i.Commit.Message, "\n")[0] + } + return "" +} + +func (i LogItem) Description() string { return "" } + +func (i LogItem) FilterValue() string { return i.Title() } + +type LogItemDelegate struct { + style *styles.Styles +} + +func (d LogItemDelegate) Height() int { return 1 } +func (d LogItemDelegate) Spacing() int { return 0 } +func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(LogItem) + if !ok { + return + } + if i.Commit == nil { + return + } + + hash := i.Commit.ID.String() + leftMargin := d.style.LogItemSelector.GetMarginLeft() + + d.style.LogItemSelector.GetWidth() + + d.style.LogItemHash.GetMarginLeft() + + d.style.LogItemHash.GetWidth() + + d.style.LogItemInactive.GetMarginLeft() + title := truncateString(i.Title(), m.Width()-leftMargin, "…") + if index == m.Index() { + fmt.Fprint(w, d.style.LogItemSelector.Render(">")+ + 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(hash[:7])+ + d.style.LogItemInactive.Render(title)) + } +} + +func truncateString(s string, max int, tail string) string { + if max < 0 { + max = 0 + } + return truncate.StringWithTail(s, uint(max), tail) +} diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go new file mode 100644 index 0000000000000000000000000000000000000000..e8c9d5e11110817afa20ea468bbbb07bf524dbc4 --- /dev/null +++ b/ui/pages/repo/repo.go @@ -0,0 +1,185 @@ +package repo + +import ( + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/code" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/components/statusbar" + "github.com/charmbracelet/soft-serve/ui/components/tabs" + "github.com/charmbracelet/soft-serve/ui/git" +) + +type tab int + +const ( + readmeTab tab = iota + filesTab + commitsTab + branchesTab + tagsTab +) + +type RepoMsg git.GitRepo + +type Repo struct { + common common.Common + rs git.GitRepoSource + selectedRepo git.GitRepo + activeTab tab + tabs *tabs.Tabs + statusbar *statusbar.StatusBar + readme *code.Code + log *Log + ref *ggit.Reference +} + +func New(common common.Common, rs git.GitRepoSource) *Repo { + sb := statusbar.New(common) + tb := tabs.New(common, []string{"Readme", "Files", "Commits", "Branches", "Tags"}) + readme := code.New(common, "", "") + readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.") + r := &Repo{ + common: common, + rs: rs, + tabs: tb, + statusbar: sb, + readme: readme, + } + return r +} + +func (r *Repo) SetSize(width, height int) { + r.common.SetSize(width, height) + hm := 4 + r.tabs.SetSize(width, height-hm) + r.statusbar.SetSize(width, height-hm) + r.readme.SetSize(width, height-hm) + if r.log != nil { + r.log.SetSize(width, height-hm) + } +} + +func (r *Repo) ShortHelp() []key.Binding { + b := make([]key.Binding, 0) + tab := r.common.Keymap.Section + tab.SetHelp("tab", "switch tab") + b = append(b, r.common.Keymap.Back) + b = append(b, tab) + return b +} + +func (r *Repo) FullHelp() [][]key.Binding { + b := make([][]key.Binding, 0) + return b +} + +func (r *Repo) Init() tea.Cmd { + return nil +} + +func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case selector.SelectMsg: + r.activeTab = 0 + cmds = append(cmds, r.tabs.Init(), r.setRepoCmd(string(msg))) + case RepoMsg: + r.selectedRepo = git.GitRepo(msg) + cmds = append(cmds, r.updateStatusBarCmd, r.updateReadmeCmd) + case tabs.ActiveTabMsg: + r.activeTab = tab(msg) + } + t, cmd := r.tabs.Update(msg) + r.tabs = t.(*tabs.Tabs) + if cmd != nil { + cmds = append(cmds, cmd) + } + s, cmd := r.statusbar.Update(msg) + r.statusbar = s.(*statusbar.StatusBar) + if cmd != nil { + cmds = append(cmds, cmd) + } + switch r.activeTab { + case readmeTab: + b, cmd := r.readme.Update(msg) + r.readme = b.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + case filesTab: + case commitsTab: + if r.log == nil { + r.log = NewLog(r.common) + cmds = append(cmds, r.log.Init()) + } + l, cmd := r.log.Update(msg) + r.log = l.(*Log) + if cmd != nil { + cmds = append(cmds, cmd) + } + case branchesTab: + case tagsTab: + } + return r, tea.Batch(cmds...) +} + +func (r *Repo) View() string { + s := r.common.Styles.RepoBody.Copy(). + Width(r.common.Width). + Height(r.common.Height) + mainStyle := lipgloss.NewStyle(). + Height(r.common.Height-4). + Margin(1, 0) + main := mainStyle.Render("") + switch r.activeTab { + case readmeTab: + main = mainStyle.Render(r.readme.View()) + case filesTab: + case commitsTab: + if r.log != nil { + main = mainStyle.Render(r.log.View()) + } + } + view := lipgloss.JoinVertical(lipgloss.Top, + r.tabs.View(), + main, + r.statusbar.View(), + ) + return s.Render(view) +} + +func (r *Repo) setRepoCmd(repo string) tea.Cmd { + return func() tea.Msg { + for _, r := range r.rs.AllRepos() { + if r.Name() == repo { + return RepoMsg(r) + } + } + return common.ErrorMsg(git.ErrMissingRepo) + } +} + +func (r *Repo) updateStatusBarCmd() tea.Msg { + branch, err := r.selectedRepo.HEAD() + if err != nil { + return common.ErrorMsg(err) + } + return statusbar.StatusBarMsg{ + Key: r.selectedRepo.Name(), + Value: "", + Info: "", + Branch: branch.Name().Short(), + } +} + +func (r *Repo) updateReadmeCmd() tea.Msg { + if r.selectedRepo == nil { + return common.ErrorCmd(git.ErrMissingRepo) + } + rm, rp := r.selectedRepo.Readme() + return r.readme.SetContent(rm, rp) +} diff --git a/ui/styles/styles.go b/ui/styles/styles.go index 795ce4994ed27e7e8a384d720ad05f6c6f2334c1..33f2003a0689a915d855e29177c486afcc6b84fe 100644 --- a/ui/styles/styles.go +++ b/ui/styles/styles.go @@ -79,6 +79,15 @@ type Styles struct { Spinner lipgloss.Style CodeNoContent lipgloss.Style + + StatusBarKey lipgloss.Style + StatusBarValue lipgloss.Style + StatusBarInfo lipgloss.Style + StatusBarBranch lipgloss.Style + + Tab lipgloss.Style + TabActive lipgloss.Style + TabSeparator lipgloss.Style } // DefaultStyles returns default styles for the UI. @@ -176,10 +185,7 @@ func DefaultStyles() *Styles { BorderBottom(true). BorderLeft(false) - s.RepoBody = lipgloss.NewStyle(). - BorderStyle(s.RepoBodyBorder). - BorderForeground(s.InactiveBorderColor). - PaddingRight(1) + s.RepoBody = lipgloss.NewStyle() s.Footer = lipgloss.NewStyle(). Height(1) @@ -302,5 +308,38 @@ func DefaultStyles() *Styles { MarginLeft(2). Foreground(lipgloss.Color("#626262")) + s.StatusBarKey = lipgloss.NewStyle(). + Bold(true). + Padding(0, 1). + Background(lipgloss.Color("#FF5FD2")). + Foreground(lipgloss.Color("#FFFF87")) + + s.StatusBarValue = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#373737")). + Foreground(lipgloss.Color("#F1F1F1")) + + s.StatusBarInfo = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#FF8EC7")). + Foreground(lipgloss.Color("#F1F1F1")) + + s.StatusBarBranch = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#6E6ED8")). + Foreground(lipgloss.Color("#F1F1F1")) + + s.Tab = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F1F1F1")) + + s.TabActive = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6E6ED8")). + Underline(true) + + s.TabSeparator = lipgloss.NewStyle(). + SetString("│"). + Padding(0, 1). + Foreground(lipgloss.Color("#777777")) + return s } diff --git a/ui/ui.go b/ui/ui.go index 932664bc9e077916935d7f338408c0c152309d84..4c024aabbc680f5e436706353d448448041738fc 100644 --- a/ui/ui.go +++ b/ui/ui.go @@ -9,6 +9,8 @@ import ( "github.com/charmbracelet/soft-serve/ui/common" "github.com/charmbracelet/soft-serve/ui/components/footer" "github.com/charmbracelet/soft-serve/ui/components/header" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/pages/repo" "github.com/charmbracelet/soft-serve/ui/pages/selection" "github.com/charmbracelet/soft-serve/ui/session" ) @@ -88,11 +90,15 @@ func (ui *UI) SetSize(width, height int) { // Init implements tea.Model. func (ui *UI) Init() tea.Cmd { + cfg := ui.s.Config() ui.pages[0] = selection.New(ui.s, ui.common) - ui.pages[1] = selection.New(ui.s, ui.common) + ui.pages[1] = repo.New(ui.common, &source{cfg.Source}) ui.SetSize(ui.common.Width, ui.common.Height) ui.state = loadedState - return ui.pages[ui.activePage].Init() + return tea.Batch( + ui.pages[0].Init(), + ui.pages[1].Init(), + ) } // Update implements tea.Model. @@ -123,11 +129,15 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, ui.common.Keymap.Quit): return ui, tea.Quit + case ui.activePage == 1 && key.Matches(msg, ui.common.Keymap.Back): + ui.activePage = 0 } case common.ErrorMsg: ui.error = msg ui.state = errorState return ui, nil + case selector.SelectMsg: + ui.activePage = (ui.activePage + 1) % 2 } m, cmd := ui.pages[ui.activePage].Update(msg) ui.pages[ui.activePage] = m.(common.Page)