wip: initial repo ui implementation

Ayman Bagabas created

Change summary

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 ++++++
ui/components/viewport/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(-)

Detailed changes

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
+	}
+}

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,
 	}

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()
 }

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 {

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() {

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)
 	}

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,
+	)
+}

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)
+}

ui/components/viewport/viewport_patch.go → 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()
 }

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
+}

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
+}

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
 }

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 ""
+	}
+}

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)
+}

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)
+}

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
 }

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)