ref(ui): use contexts

Ayman Bagabas created

Change summary

ui/common/common.go             |  75 +++++++++++++-
ui/git.go                       |  25 ----
ui/git/git.go                   |  34 ++---
ui/pages/repo/files.go          |  17 ++
ui/pages/repo/log.go            |  27 +++-
ui/pages/repo/logitem.go        |   1 
ui/pages/repo/readme.go         |  32 +++--
ui/pages/repo/refs.go           |  25 +++
ui/pages/repo/repo.go           | 185 ++++++++++++++++++++++------------
ui/pages/selection/item.go      |  60 ++++++++++
ui/pages/selection/selection.go |  79 ++++----------
ui/styles/styles.go             |   5 
ui/ui.go                        |  80 +++++++++-----
13 files changed, 408 insertions(+), 237 deletions(-)

Detailed changes

ui/common/common.go πŸ”—

@@ -1,20 +1,56 @@
 package common
 
 import (
+	"context"
+
 	"github.com/aymanbagabas/go-osc52"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/charmbracelet/soft-serve/ui/keymap"
 	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/gliderlabs/ssh"
 	zone "github.com/lrstanley/bubblezone"
 )
 
+type contextKey struct {
+	name string
+}
+
+// Keys to use for context.Context.
+var (
+	ConfigKey = &contextKey{"config"}
+	RepoKey   = &contextKey{"repo"}
+)
+
 // Common is a struct all components should embed.
 type Common struct {
-	Copy   *osc52.Output
-	Styles *styles.Styles
-	KeyMap *keymap.KeyMap
-	Width  int
-	Height int
-	Zone   *zone.Manager
+	ctx           context.Context
+	Width, Height int
+	Styles        *styles.Styles
+	KeyMap        *keymap.KeyMap
+	Copy          *osc52.Output
+	Zone          *zone.Manager
+}
+
+// NewCommon returns a new Common struct.
+func NewCommon(ctx context.Context, copy *osc52.Output, width, height int) Common {
+	if ctx == nil {
+		ctx = context.TODO()
+	}
+	return Common{
+		ctx:    ctx,
+		Width:  width,
+		Height: height,
+		Copy:   copy,
+		Styles: styles.DefaultStyles(),
+		KeyMap: keymap.DefaultKeyMap(),
+		Zone:   zone.New(),
+	}
+}
+
+// SetValue sets a value in the context.
+func (c *Common) SetValue(key, value interface{}) {
+	c.ctx = context.WithValue(c.ctx, key, value)
 }
 
 // SetSize sets the width and height of the common struct.
@@ -22,3 +58,30 @@ func (c *Common) SetSize(width, height int) {
 	c.Width = width
 	c.Height = height
 }
+
+// Config returns the server config.
+func (c *Common) Config() *config.Config {
+	v := c.ctx.Value(ConfigKey)
+	if cfg, ok := v.(*config.Config); ok {
+		return cfg
+	}
+	return nil
+}
+
+// Repo returns the repository.
+func (c *Common) Repo() *git.Repository {
+	v := c.ctx.Value(RepoKey)
+	if r, ok := v.(*git.Repository); ok {
+		return r
+	}
+	return nil
+}
+
+// PublicKey returns the public key.
+func (c *Common) PublicKey() ssh.PublicKey {
+	v := c.ctx.Value(ssh.ContextKeyPublicKey)
+	if p, ok := v.(ssh.PublicKey); ok {
+		return p
+	}
+	return nil
+}

ui/git.go πŸ”—

@@ -1,25 +0,0 @@
-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 πŸ”—

@@ -4,32 +4,28 @@ import (
 	"errors"
 	"fmt"
 
-	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/proto"
 )
 
 // ErrMissingRepo indicates that the requested repository could not be found.
 var ErrMissingRepo = errors.New("missing repo")
 
-// GitRepo is an interface for Git repositories.
-type GitRepo interface {
-	Repo() string
-	Name() string
-	Description() string
-	Readme() (string, string)
-	HEAD() (*git.Reference, error)
-	Commit(string) (*git.Commit, 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)
-	IsPrivate() bool
+// Repository is a Git repository with its metadata.
+type Repository struct {
+	Repo proto.Repository
+	Info proto.Metadata
 }
 
-// GitRepoSource is an interface for Git repository factory.
-type GitRepoSource interface {
-	GetRepo(string) (GitRepo, error)
-	AllRepos() []GitRepo
+// Readme returns the repository's README.
+func (r *Repository) Readme() (readme string, path string) {
+	readme, path, _ = r.LatestFile("README*")
+	return
+}
+
+// LatestFile returns the contents of the latest file at the specified path in
+// the repository and its file path.
+func (r *Repository) LatestFile(pattern string) (string, string, error) {
+	return proto.LatestFile(r.Repo, pattern)
 }
 
 // RepoURL returns the URL of the repository.

ui/pages/repo/files.go πŸ”—

@@ -3,6 +3,7 @@ package repo
 import (
 	"errors"
 	"fmt"
+	"log"
 	"path/filepath"
 
 	"github.com/alecthomas/chroma/lexers"
@@ -51,7 +52,7 @@ type Files struct {
 	selector       *selector.Selector
 	ref            *ggit.Reference
 	activeView     filesView
-	repo           git.GitRepo
+	repo           *git.Repository
 	code           *code.Code
 	path           string
 	currentItem    *FileItem
@@ -200,8 +201,7 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
-		f.repo = git.GitRepo(msg)
-		cmds = append(cmds, f.Init())
+		f.repo = msg
 	case RefMsg:
 		f.ref = msg
 		cmds = append(cmds, f.Init())
@@ -320,14 +320,17 @@ func (f *Files) updateFilesCmd() tea.Msg {
 	files := make([]selector.IdentifiableItem, 0)
 	dirs := make([]selector.IdentifiableItem, 0)
 	if f.ref == nil {
+		log.Printf("ui: files: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
-	t, err := f.repo.Tree(f.ref, f.path)
+	t, err := f.repo.Repo.Repository().TreePath(f.ref, f.path)
 	if err != nil {
+		log.Printf("ui: files: error getting tree %v", err)
 		return common.ErrorMsg(err)
 	}
 	ents, err := t.Entries()
 	if err != nil {
+		log.Printf("ui: files: error listing files %v", err)
 		return common.ErrorMsg(err)
 	}
 	ents.Sort()
@@ -347,6 +350,7 @@ func (f *Files) selectTreeCmd() tea.Msg {
 		f.selector.Select(0)
 		return f.updateFilesCmd()
 	}
+	log.Printf("ui: files: current item is not a tree")
 	return common.ErrorMsg(errNoFileSelected)
 }
 
@@ -355,25 +359,30 @@ func (f *Files) selectFileCmd() tea.Msg {
 	if i != nil && !i.entry.IsTree() {
 		fi := i.entry.File()
 		if i.Mode().IsDir() || f == nil {
+			log.Printf("ui: files: current item is not a file")
 			return common.ErrorMsg(errInvalidFile)
 		}
 		bin, err := fi.IsBinary()
 		if err != nil {
 			f.path = filepath.Dir(f.path)
+			log.Printf("ui: files: error checking if file is binary %v", err)
 			return common.ErrorMsg(err)
 		}
 		if bin {
 			f.path = filepath.Dir(f.path)
+			log.Printf("ui: files: file is binary")
 			return common.ErrorMsg(errBinaryFile)
 		}
 		c, err := fi.Bytes()
 		if err != nil {
 			f.path = filepath.Dir(f.path)
+			log.Printf("ui: files: error reading file %v", err)
 			return common.ErrorMsg(err)
 		}
 		f.lastSelected = append(f.lastSelected, f.selector.Index())
 		return FileContentMsg{string(c), i.entry.Name()}
 	}
+	log.Printf("ui: files: current item is not a file")
 	return common.ErrorMsg(errNoFileSelected)
 }
 

ui/pages/repo/log.go πŸ”—

@@ -2,6 +2,7 @@ package repo
 
 import (
 	"fmt"
+	"log"
 	"strings"
 	"time"
 
@@ -47,7 +48,7 @@ type Log struct {
 	selector       *selector.Selector
 	vp             *viewport.Viewport
 	activeView     logView
-	repo           git.GitRepo
+	repo           *git.Repository
 	ref            *ggit.Reference
 	count          int64
 	nextPage       int
@@ -77,9 +78,8 @@ func NewLog(common common.Common) *Log {
 	selector.KeyMap.NextPage = common.KeyMap.NextPage
 	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 	l.selector = selector
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = common.Styles.Spinner
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(common.Styles.Spinner))
 	l.spinner = s
 	return l
 }
@@ -189,8 +189,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
-		l.repo = git.GitRepo(msg)
-		cmds = append(cmds, l.Init())
+		l.repo = msg
 	case RefMsg:
 		l.ref = msg
 		cmds = append(cmds, l.Init())
@@ -245,6 +244,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if l.activeView == logViewDiff {
 			l.activeView = logViewCommits
 			l.selectedCommit = nil
+			cmds = append(cmds, updateStatusBarCmd)
 		}
 	case selector.ActiveMsg:
 		switch sel := msg.IdentifiableItem.(type) {
@@ -326,7 +326,9 @@ func (l *Log) View() string {
 			msg += "s"
 		}
 		msg += "…"
-		return msg
+		return l.common.Styles.SpinnerContainer.Copy().
+			Height(l.common.Height).
+			Render(msg)
 	}
 	switch l.activeView {
 	case logViewCommits:
@@ -374,10 +376,12 @@ func (l *Log) StatusBarInfo() string {
 
 func (l *Log) countCommitsCmd() tea.Msg {
 	if l.ref == nil {
+		log.Printf("ui: log: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
-	count, err := l.repo.CountCommits(l.ref)
+	count, err := l.repo.Repo.Repository().CountCommits(l.ref)
 	if err != nil {
+		log.Printf("ui: error counting commits: %v", err)
 		return common.ErrorMsg(err)
 	}
 	return LogCountMsg(count)
@@ -394,6 +398,7 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 		}
 	}
 	if l.ref == nil {
+		log.Printf("ui: log: ref is nil")
 		return common.ErrorMsg(errNoRef)
 	}
 	items := make([]selector.IdentifiableItem, count)
@@ -401,8 +406,9 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 	limit := l.selector.PerPage()
 	skip := page * limit
 	// CommitsByPage pages start at 1
-	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
+	cc, err := l.repo.Repo.Repository().CommitsByPage(l.ref, page+1, limit)
 	if err != nil {
+		log.Printf("ui: error loading commits: %v", err)
 		return common.ErrorMsg(err)
 	}
 	for i, c := range cc {
@@ -422,8 +428,9 @@ func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
 }
 
 func (l *Log) loadDiffCmd() tea.Msg {
-	diff, err := l.repo.Diff(l.selectedCommit)
+	diff, err := l.repo.Repo.Repository().Diff(l.selectedCommit)
 	if err != nil {
+		log.Printf("ui: error loading diff: %v", err)
 		return common.ErrorMsg(err)
 	}
 	return LogDiffMsg(diff)

ui/pages/repo/logitem.go πŸ”—

@@ -26,6 +26,7 @@ func (i LogItem) ID() string {
 	return i.Hash()
 }
 
+// Hash returns the commit hash.
 func (i LogItem) Hash() string {
 	return i.Commit.ID.String()
 }

ui/pages/repo/readme.go πŸ”—

@@ -10,14 +10,17 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/git"
 )
 
-type ReadmeMsg struct{}
+// ReadmeMsg is a message sent when the readme is loaded.
+type ReadmeMsg struct {
+	Msg tea.Msg
+}
 
 // Readme is the readme component page.
 type Readme struct {
 	common common.Common
 	code   *code.Code
 	ref    RefMsg
-	repo   git.GitRepo
+	repo   *git.Repository
 }
 
 // NewReadme creates a new readme model.
@@ -64,15 +67,7 @@ func (r *Readme) FullHelp() [][]key.Binding {
 
 // Init implements tea.Model.
 func (r *Readme) Init() tea.Cmd {
-	if r.repo == nil {
-		return common.ErrorCmd(git.ErrMissingRepo)
-	}
-	rm, rp := r.repo.Readme()
-	r.code.GotoTop()
-	return tea.Batch(
-		r.code.SetContent(rm, rp),
-		r.updateReadmeCmd,
-	)
+	return r.updateReadmeCmd
 }
 
 // Update implements tea.Model.
@@ -80,8 +75,7 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
-		r.repo = git.GitRepo(msg)
-		cmds = append(cmds, r.Init())
+		r.repo = msg
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds, r.Init())
@@ -110,5 +104,15 @@ func (r *Readme) StatusBarInfo() string {
 }
 
 func (r *Readme) updateReadmeCmd() tea.Msg {
-	return ReadmeMsg{}
+	m := ReadmeMsg{}
+	if r.repo == nil {
+		return common.ErrorCmd(git.ErrMissingRepo)
+	}
+	rm, rp := r.repo.Readme()
+	r.code.GotoTop()
+	cmd := r.code.SetContent(rm, rp)
+	if cmd != nil {
+		m.Msg = cmd()
+	}
+	return m
 }

ui/pages/repo/refs.go πŸ”—

@@ -3,6 +3,7 @@ package repo
 import (
 	"errors"
 	"fmt"
+	"log"
 	"sort"
 	"strings"
 
@@ -19,6 +20,9 @@ var (
 	errNoRef = errors.New("no reference specified")
 )
 
+// RefMsg is a message that contains a git.Reference.
+type RefMsg *ggit.Reference
+
 // RefItemsMsg is a message that contains a list of RefItem.
 type RefItemsMsg struct {
 	prefix string
@@ -29,7 +33,7 @@ type RefItemsMsg struct {
 type Refs struct {
 	common    common.Common
 	selector  *selector.Selector
-	repo      git.GitRepo
+	repo      *git.Repository
 	ref       *ggit.Reference
 	activeRef *ggit.Reference
 	refPrefix string
@@ -104,8 +108,7 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case RepoMsg:
 		r.selector.Select(0)
-		r.repo = git.GitRepo(msg)
-		cmds = append(cmds, r.Init())
+		r.repo = msg
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds, r.Init())
@@ -169,8 +172,9 @@ func (r *Refs) StatusBarInfo() string {
 
 func (r *Refs) updateItemsCmd() tea.Msg {
 	its := make(RefItems, 0)
-	refs, err := r.repo.References()
+	refs, err := r.repo.Repo.Repository().References()
 	if err != nil {
+		log.Printf("ui: error getting references: %v", err)
 		return common.ErrorMsg(err)
 	}
 	for _, ref := range refs {
@@ -194,3 +198,16 @@ func switchRefCmd(ref *ggit.Reference) tea.Cmd {
 		return RefMsg(ref)
 	}
 }
+
+// UpdateRefCmd gets the repository's HEAD reference and sends a RefMsg.
+func UpdateRefCmd(repo *git.Repository) tea.Cmd {
+	return func() tea.Msg {
+		ref, err := repo.Repo.Repository().HEAD()
+		if err != nil {
+			log.Printf("ui: error getting HEAD reference: %v", err)
+			return common.ErrorMsg(err)
+		}
+		log.Printf("HEAD: %s", ref.Name())
+		return RefMsg(ref)
+	}
+}

ui/pages/repo/repo.go πŸ”—

@@ -9,7 +9,6 @@ import (
 	"github.com/charmbracelet/bubbles/spinner"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
 	ggit "github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/footer"
@@ -56,10 +55,7 @@ type ResetURLMsg struct{}
 type UpdateStatusBarMsg struct{}
 
 // RepoMsg is a message that contains a git.Repository.
-type RepoMsg git.GitRepo
-
-// RefMsg is a message that contains a git.Reference.
-type RefMsg *ggit.Reference
+type RepoMsg *git.Repository
 
 // BackMsg is a message to go back to the previous view.
 type BackMsg struct{}
@@ -67,18 +63,20 @@ type BackMsg struct{}
 // Repo is a view for a git repository.
 type Repo struct {
 	common       common.Common
-	cfg          *config.Config
-	selectedRepo git.GitRepo
+	selectedRepo *git.Repository
 	activeTab    tab
 	tabs         *tabs.Tabs
 	statusbar    *statusbar.StatusBar
 	panes        []common.Component
 	ref          *ggit.Reference
 	copyURL      time.Time
+	state        state
+	spinner      spinner.Model
+	panesReady   [lastTab]bool
 }
 
 // New returns a new Repo.
-func New(cfg *config.Config, c common.Common) *Repo {
+func New(c common.Common) *Repo {
 	sb := statusbar.New(c)
 	ts := make([]string, lastTab)
 	// Tabs must match the order of tab constants above.
@@ -99,12 +97,15 @@ func New(cfg *config.Config, c common.Common) *Repo {
 		branches,
 		tags,
 	}
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(c.Styles.Spinner))
 	r := &Repo{
-		cfg:       cfg,
 		common:    c,
 		tabs:      tb,
 		statusbar: sb,
 		panes:     panes,
+		state:     loadingState,
+		spinner:   s,
 	}
 	return r
 }
@@ -162,16 +163,20 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case RepoMsg:
+		// Set the state to loading when we get a new repository.
+		r.state = loadingState
+		r.panesReady = [lastTab]bool{}
 		r.activeTab = 0
-		r.selectedRepo = git.GitRepo(msg)
+		r.selectedRepo = msg
 		cmds = append(cmds,
 			r.tabs.Init(),
-			r.updateRefCmd,
+			// This will set the selected repo in each pane's model.
 			r.updateModels(msg),
 		)
 	case RefMsg:
 		r.ref = msg
 		for _, p := range r.panes {
+			// Init will initiate each pane's model with its contents.
 			cmds = append(cmds, p.Init())
 		}
 		cmds = append(cmds,
@@ -200,7 +205,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 		if r.selectedRepo != nil {
 			cmds = append(cmds, r.updateStatusBarCmd)
-			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Repo())
+			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Info.Name())
 			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
 				cmds = append(cmds, r.copyURLCmd())
 			}
@@ -221,41 +226,32 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	case CopyURLMsg:
-		r.common.Copy.Copy(
-			git.RepoURL(r.cfg.Host, r.cfg.Port, r.selectedRepo.Repo()),
-		)
+		if cfg := r.common.Config(); cfg != nil {
+			host := cfg.Host
+			port := cfg.SSH.Port
+			r.common.Copy.Copy(
+				git.RepoURL(host, port, r.selectedRepo.Info.Name()),
+			)
+		}
 	case ResetURLMsg:
 		r.copyURL = time.Time{}
-	case ReadmeMsg:
-	case FileItemsMsg:
-		f, cmd := r.panes[filesTab].Update(msg)
-		r.panes[filesTab] = f.(*Files)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	// The Log bubble is the only bubble that uses a spinner, so this is fine
-	// for now. We need to pass the TickMsg to the Log bubble when the Log is
-	// loading but not the current selected tab so that the spinner works.
-	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
-		l, cmd := r.panes[commitsTab].Update(msg)
-		r.panes[commitsTab] = l.(*Log)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	case RefItemsMsg:
-		switch msg.prefix {
-		case ggit.RefsHeads:
-			b, cmd := r.panes[branchesTab].Update(msg)
-			r.panes[branchesTab] = b.(*Refs)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		case ggit.RefsTags:
-			t, cmd := r.panes[tagsTab].Update(msg)
-			r.panes[tagsTab] = t.(*Refs)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
+	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
+		cmds = append(cmds, r.updateRepo(msg))
+	// We have two spinners, one is used to when loading the repository and the
+	// other is used when loading the log.
+	// Check if the spinner ID matches the spinner model.
+	case spinner.TickMsg:
+		switch msg.ID {
+		case r.spinner.ID():
+			if r.state == loadingState {
+				s, cmd := r.spinner.Update(msg)
+				r.spinner = s
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
 			}
+		default:
+			cmds = append(cmds, r.updateRepo(msg))
 		}
 	case UpdateStatusBarMsg:
 		cmds = append(cmds, r.updateStatusBarCmd)
@@ -289,15 +285,24 @@ func (r *Repo) View() string {
 		r.common.Styles.Tabs.GetVerticalFrameSize()
 	mainStyle := repoBodyStyle.
 		Height(r.common.Height - hm)
-	main := r.common.Zone.Mark(
+	var main string
+	var statusbar string
+	switch r.state {
+	case loadingState:
+		main = fmt.Sprintf("%s loading…", r.spinner.View())
+	case loadedState:
+		main = r.panes[r.activeTab].View()
+		statusbar = r.statusbar.View()
+	}
+	main = r.common.Zone.Mark(
 		"repo-main",
-		mainStyle.Render(r.panes[r.activeTab].View()),
+		mainStyle.Render(main),
 	)
 	view := lipgloss.JoinVertical(lipgloss.Top,
 		r.headerView(),
 		r.tabs.View(),
 		main,
-		r.statusbar.View(),
+		statusbar,
 	)
 	return s.Render(view)
 }
@@ -306,10 +311,9 @@ func (r *Repo) headerView() string {
 	if r.selectedRepo == nil {
 		return ""
 	}
-	cfg := r.cfg
 	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
-	name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name())
-	desc := r.selectedRepo.Description()
+	name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Info.Name())
+	desc := r.selectedRepo.Info.Description()
 	if desc == "" {
 		desc = name
 		name = ""
@@ -319,13 +323,16 @@ func (r *Repo) headerView() string {
 	urlStyle := r.common.Styles.URLStyle.Copy().
 		Width(r.common.Width - lipgloss.Width(desc) - 1).
 		Align(lipgloss.Right)
-	url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
+	var url string
+	if cfg := r.common.Config(); cfg != nil {
+		url = git.RepoURL(cfg.Host, cfg.SSH.Port, r.selectedRepo.Info.Name())
+	}
 	if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
 		url = "copied!"
 	}
 	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
 	url = r.common.Zone.Mark(
-		fmt.Sprintf("%s-url", r.selectedRepo.Repo()),
+		fmt.Sprintf("%s-url", r.selectedRepo.Info.Name()),
 		urlStyle.Render(url),
 	)
 	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
@@ -351,24 +358,13 @@ func (r *Repo) updateStatusBarCmd() tea.Msg {
 		ref = r.ref.Name().Short()
 	}
 	return statusbar.StatusBarMsg{
-		Key:    r.selectedRepo.Repo(),
+		Key:    r.selectedRepo.Info.Name(),
 		Value:  value,
 		Info:   info,
 		Branch: fmt.Sprintf("* %s", ref),
 	}
 }
 
-func (r *Repo) updateRefCmd() tea.Msg {
-	if r.selectedRepo == nil {
-		return nil
-	}
-	head, err := r.selectedRepo.HEAD()
-	if err != nil {
-		return common.ErrorMsg(err)
-	}
-	return RefMsg(head)
-}
-
 func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
 	cmds := make([]tea.Cmd, 0)
 	for i, b := range r.panes {
@@ -381,6 +377,67 @@ func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
 	return tea.Batch(cmds...)
 }
 
+func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
+		switch msg.(type) {
+		case LogItemsMsg:
+			r.panesReady[commitsTab] = true
+		}
+		l, cmd := r.panes[commitsTab].Update(msg)
+		r.panes[commitsTab] = l.(*Log)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case FileItemsMsg:
+		r.panesReady[filesTab] = true
+		f, cmd := r.panes[filesTab].Update(msg)
+		r.panes[filesTab] = f.(*Files)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case RefItemsMsg:
+		switch msg.prefix {
+		case ggit.RefsHeads:
+			r.panesReady[branchesTab] = true
+			b, cmd := r.panes[branchesTab].Update(msg)
+			r.panes[branchesTab] = b.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		case ggit.RefsTags:
+			r.panesReady[tagsTab] = true
+			t, cmd := r.panes[tagsTab].Update(msg)
+			r.panes[tagsTab] = t.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case ReadmeMsg:
+		r.panesReady[readmeTab] = true
+	}
+	if r.isReady() {
+		r.state = loadedState
+	}
+	return tea.Batch(cmds...)
+}
+
+func (r *Repo) isReady() bool {
+	ready := true
+	// We purposely ignore the log pane here because it has its own spinner.
+	for _, b := range []bool{
+		r.panesReady[filesTab], r.panesReady[branchesTab],
+		r.panesReady[tagsTab], r.panesReady[readmeTab],
+	} {
+		if !b {
+			ready = false
+			break
+		}
+	}
+	return ready
+}
+
 func (r *Repo) copyURLCmd() tea.Cmd {
 	r.copyURL = time.Now()
 	return tea.Batch(

ui/pages/selection/item.go πŸ”—

@@ -3,6 +3,8 @@ package selection
 import (
 	"fmt"
 	"io"
+	"log"
+	"sort"
 	"strings"
 	"time"
 
@@ -10,29 +12,77 @@ import (
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/proto"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/dustin/go-humanize"
 )
 
+var _ sort.Interface = Items{}
+
+// Items is a list of Item.
+type Items []Item
+
+// Len implements sort.Interface.
+func (it Items) Len() int {
+	return len(it)
+}
+
+// Less implements sort.Interface.
+func (it Items) Less(i int, j int) bool {
+	return it[i].lastUpdate.After(it[j].lastUpdate)
+}
+
+// Swap implements sort.Interface.
+func (it Items) Swap(i int, j int) {
+	it[i], it[j] = it[j], it[i]
+}
+
 // Item represents a single item in the selector.
 type Item struct {
-	repo       git.GitRepo
+	repo       proto.Repository
+	info       proto.Metadata
 	lastUpdate time.Time
 	cmd        string
 	copied     time.Time
 }
 
+// New creates a new Item.
+func NewItem(info proto.Metadata, cfg *config.Config) (Item, error) {
+	repo, err := info.Open()
+	if err != nil {
+		log.Printf("error opening repo: %v", err)
+		return Item{}, err
+	}
+	lu, err := repo.Repository().LatestCommitTime()
+	if err != nil {
+		log.Printf("error getting latest commit time: %v", err)
+		return Item{}, err
+	}
+	return Item{
+		repo:       repo,
+		info:       info,
+		lastUpdate: lu,
+		cmd:        git.RepoURL(cfg.Host, cfg.SSH.Port, info.Name()),
+	}, nil
+}
+
 // ID implements selector.IdentifiableItem.
 func (i Item) ID() string {
-	return i.repo.Repo()
+	return i.info.Name()
 }
 
 // Title returns the item title. Implements list.DefaultItem.
-func (i Item) Title() string { return i.repo.Name() }
+func (i Item) Title() string {
+	if pn := i.info.ProjectName(); pn != "" {
+		return pn
+	}
+	return i.info.Name()
+}
 
 // Description returns the item description. Implements list.DefaultItem.
-func (i Item) Description() string { return i.repo.Description() }
+func (i Item) Description() string { return i.info.Description() }
 
 // FilterValue implements list.Item.
 func (i Item) FilterValue() string { return i.Title() }
@@ -101,7 +151,7 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 
 	title := i.Title()
 	title = common.TruncateString(title, m.Width()-styles.Base.GetHorizontalFrameSize())
-	if i.repo.IsPrivate() {
+	if i.info.IsPrivate() {
 		title += " πŸ”’"
 	}
 	if isSelected {

ui/pages/selection/selection.go πŸ”—

@@ -2,20 +2,18 @@ package selection
 
 import (
 	"fmt"
-	"strings"
+	"log"
+	"sort"
 
 	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/proto"
 	"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/tabs"
-	"github.com/charmbracelet/soft-serve/ui/git"
-	"github.com/gliderlabs/ssh"
 )
 
 type pane int
@@ -35,8 +33,6 @@ func (p pane) String() string {
 
 // Selection is the model for the selection screen/page.
 type Selection struct {
-	cfg          *config.Config
-	pk           ssh.PublicKey
 	common       common.Common
 	readme       *code.Code
 	readmeHeight int
@@ -46,7 +42,7 @@ type Selection struct {
 }
 
 // New creates a new selection model.
-func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
+func New(common common.Common) *Selection {
 	ts := make([]string, lastPane)
 	for i, b := range []pane{selectorPane, readmePane} {
 		ts[i] = b.String()
@@ -58,8 +54,6 @@ func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection
 	t.TabDot = common.Styles.TopLevelActiveTabDot.Copy()
 	t.UseDot = true
 	sel := &Selection{
-		cfg:        cfg,
-		pk:         pk,
 		common:     common,
 		activePane: selectorPane, // start with the selector focused
 		tabs:       t,
@@ -184,59 +178,34 @@ func (s *Selection) FullHelp() [][]key.Binding {
 // Init implements tea.Model.
 func (s *Selection) Init() tea.Cmd {
 	var readmeCmd tea.Cmd
-	items := make([]selector.IdentifiableItem, 0)
-	cfg := s.cfg
-	pk := s.pk
+	cfg := s.common.Config()
+	pk := s.common.PublicKey()
+	if cfg == nil || pk == nil {
+		return nil
+	}
+	repos, err := cfg.ListRepos()
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	sortedItems := make(Items, 0)
 	// Put configured repos first
-	for _, r := range cfg.Repos {
-		acc := cfg.AuthRepo(r.Repo, pk)
-		if r.Private && acc < proto.ReadOnlyAccess {
+	for _, r := range repos {
+		log.Printf("adding configured repo %s", r.Name())
+		acc := cfg.AuthRepo(r.Name(), pk)
+		if r.IsPrivate() && acc < proto.ReadOnlyAccess {
 			continue
 		}
-		repo, err := cfg.Source.GetRepo(r.Repo)
+		item, err := NewItem(r, cfg)
 		if err != nil {
+			log.Printf("ui: failed to create item for %s: %v", r.Name(), err)
 			continue
 		}
-		items = append(items, Item{
-			repo: repo,
-			cmd:  git.RepoURL(cfg.Host, cfg.Port, r.Repo),
-		})
+		sortedItems = append(sortedItems, item)
 	}
-	for _, r := range cfg.Source.AllRepos() {
-		if r.Repo() == "config" {
-			rm, rp := r.Readme()
-			s.readmeHeight = strings.Count(rm, "\n")
-			readmeCmd = s.readme.SetContent(rm, rp)
-		}
-		acc := cfg.AuthRepo(r.Repo(), pk)
-		if r.IsPrivate() && acc < proto.ReadOnlyAccess {
-			continue
-		}
-		exists := false
-		lc, err := r.Commit("HEAD")
-		if err != nil {
-			return common.ErrorCmd(err)
-		}
-		lastUpdate := lc.Committer.When
-		if lastUpdate.IsZero() {
-			lastUpdate = lc.Author.When
-		}
-		for i, item := range items {
-			item := item.(Item)
-			if item.repo.Repo() == r.Repo() {
-				exists = true
-				item.lastUpdate = lastUpdate
-				items[i] = item
-				break
-			}
-		}
-		if !exists {
-			items = append(items, Item{
-				repo:       r,
-				lastUpdate: lastUpdate,
-				cmd:        git.RepoURL(cfg.Host, cfg.Port, r.Name()),
-			})
-		}
+	sort.Sort(sortedItems)
+	items := make([]selector.IdentifiableItem, len(sortedItems))
+	for i, it := range sortedItems {
+		items[i] = it
 	}
 	return tea.Batch(
 		s.selector.Init(),

ui/styles/styles.go πŸ”—

@@ -122,7 +122,8 @@ type Styles struct {
 		NoItems     lipgloss.Style
 	}
 
-	Spinner lipgloss.Style
+	Spinner          lipgloss.Style
+	SpinnerContainer lipgloss.Style
 
 	CodeNoContent lipgloss.Style
 
@@ -409,6 +410,8 @@ func DefaultStyles() *Styles {
 		MarginLeft(2).
 		Foreground(lipgloss.Color("205"))
 
+	s.SpinnerContainer = lipgloss.NewStyle()
+
 	s.CodeNoContent = lipgloss.NewStyle().
 		SetString("No Content.").
 		MarginTop(1).

ui/ui.go πŸ”—

@@ -1,11 +1,13 @@
 package ui
 
 import (
+	"errors"
+	"log"
+
 	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/ui/common"
 	"github.com/charmbracelet/soft-serve/ui/components/footer"
 	"github.com/charmbracelet/soft-serve/ui/components/header"
@@ -13,7 +15,6 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/git"
 	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 	"github.com/charmbracelet/soft-serve/ui/pages/selection"
-	"github.com/gliderlabs/ssh"
 )
 
 type page int
@@ -33,9 +34,7 @@ const (
 
 // UI is the main UI model.
 type UI struct {
-	cfg         *config.Config
-	session     ssh.Session
-	rs          git.GitRepoSource
+	serverName  string
 	initialRepo string
 	common      common.Common
 	pages       []common.Component
@@ -48,13 +47,14 @@ type UI struct {
 }
 
 // New returns a new UI model.
-func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI {
-	src := &source{cfg.Source}
-	h := header.New(c, cfg.Name)
+func New(c common.Common, initialRepo string) *UI {
+	var serverName string
+	if cfg := c.Config(); cfg != nil {
+		serverName = cfg.ServerName
+	}
+	h := header.New(c, serverName)
 	ui := &UI{
-		cfg:         cfg,
-		session:     s,
-		rs:          src,
+		serverName:  serverName,
 		common:      c,
 		pages:       make([]common.Component, 2), // selection & repo
 		activePage:  selectionPage,
@@ -136,15 +136,8 @@ func (ui *UI) SetSize(width, height int) {
 
 // Init implements tea.Model.
 func (ui *UI) Init() tea.Cmd {
-	ui.pages[selectionPage] = selection.New(
-		ui.cfg,
-		ui.session.PublicKey(),
-		ui.common,
-	)
-	ui.pages[repoPage] = repo.New(
-		ui.cfg,
-		ui.common,
-	)
+	ui.pages[selectionPage] = selection.New(ui.common)
+	ui.pages[repoPage] = repo.New(ui.common)
 	ui.SetSize(ui.common.Width, ui.common.Height)
 	cmds := make([]tea.Cmd, 0)
 	cmds = append(cmds,
@@ -171,6 +164,7 @@ func (ui *UI) IsFiltering() bool {
 
 // Update implements tea.Model.
 func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	log.Printf("msg received: %T", msg)
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
@@ -220,9 +214,11 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			ui.showFooter = !ui.showFooter
 		}
 	case repo.RepoMsg:
+		ui.common.SetValue(common.RepoKey, msg)
 		ui.activePage = repoPage
 		// Show the footer on repo page if show all is set.
 		ui.showFooter = ui.footer.ShowAll()
+		cmds = append(cmds, repo.UpdateRefCmd(msg))
 	case common.ErrorMsg:
 		ui.error = msg
 		ui.state = errorState
@@ -292,24 +288,48 @@ func (ui *UI) View() string {
 	)
 }
 
+func (ui *UI) openRepo(rn string) (*git.Repository, error) {
+	cfg := ui.common.Config()
+	if cfg == nil {
+		return nil, errors.New("config is nil")
+	}
+	repos, err := cfg.ListRepos()
+	if err != nil {
+		log.Printf("ui: failed to list repos: %v", err)
+		return nil, err
+	}
+	for _, r := range repos {
+		if r.Name() == rn {
+			re, err := cfg.Open(rn)
+			if err != nil {
+				log.Printf("ui: failed to open repo: %v", err)
+				return nil, err
+			}
+			return &git.Repository{
+				Info: r,
+				Repo: re,
+			}, nil
+		}
+	}
+	return nil, git.ErrMissingRepo
+}
+
 func (ui *UI) setRepoCmd(rn string) tea.Cmd {
 	return func() tea.Msg {
-		for _, r := range ui.rs.AllRepos() {
-			if r.Repo() == rn {
-				return repo.RepoMsg(r)
-			}
+		r, err := ui.openRepo(rn)
+		if err != nil {
+			return common.ErrorMsg(err)
 		}
-		return common.ErrorMsg(git.ErrMissingRepo)
+		return repo.RepoMsg(r)
 	}
 }
 
 func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
 	return func() tea.Msg {
-		for _, r := range ui.rs.AllRepos() {
-			if r.Repo() == rn {
-				return repo.RepoMsg(r)
-			}
+		r, err := ui.openRepo(rn)
+		if err != nil {
+			return nil
 		}
-		return nil
+		return repo.RepoMsg(r)
 	}
 }