Detailed changes
@@ -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
+ }
+}
@@ -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,
}
@@ -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()
}
@@ -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 {
@@ -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() {
@@ -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)
}
@@ -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,
+ )
+}
@@ -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)
+}
@@ -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()
}
@@ -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
+}
@@ -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
+}
@@ -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
}
@@ -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 ""
+ }
+}
@@ -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)
+}
@@ -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)
+}
@@ -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
}
@@ -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)