feat: repo files tab

Ayman Bagabas created

Change summary

ui/components/selector/selector.go |  15 +
ui/pages/repo/files.go             | 273 ++++++++++++++++++++++++++++++++
ui/pages/repo/filesitem.go         | 119 +++++++++++++
ui/pages/repo/log.go               |  58 ++++--
ui/pages/repo/repo.go              |  75 ++++++--
ui/pages/selection/selection.go    |   3 
ui/ui.go                           |  12 
7 files changed, 503 insertions(+), 52 deletions(-)

Detailed changes

ui/components/selector/selector.go 🔗

@@ -21,6 +21,11 @@ type IdentifiableItem interface {
 	ID() string
 }
 
+// ItemDelegate is a wrapper around list.ItemDelegate.
+type ItemDelegate interface {
+	list.ItemDelegate
+}
+
 // SelectMsg is a message that is sent when an item is selected.
 type SelectMsg struct{ IdentifiableItem }
 
@@ -28,7 +33,7 @@ type SelectMsg struct{ IdentifiableItem }
 type ActiveMsg struct{ IdentifiableItem }
 
 // New creates a new selector.
-func New(common common.Common, items []IdentifiableItem, delegate list.ItemDelegate) *Selector {
+func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector {
 	itms := make([]list.Item, len(items))
 	for i, item := range items {
 		itms[i] = item
@@ -109,8 +114,12 @@ func (s *Selector) SetSize(width, height int) {
 }
 
 // SetItems sets the items in the selector.
-func (s *Selector) SetItems(items []list.Item) tea.Cmd {
-	return s.Model.SetItems(items)
+func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd {
+	its := make([]list.Item, len(items))
+	for i, item := range items {
+		its[i] = item
+	}
+	return s.Model.SetItems(its)
 }
 
 // Index returns the index of the selected item.

ui/pages/repo/files.go 🔗

@@ -0,0 +1,273 @@
+package repo
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"path/filepath"
+
+	tea "github.com/charmbracelet/bubbletea"
+	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/git"
+)
+
+type filesView int
+
+const (
+	filesViewFiles filesView = iota
+	filesViewContent
+)
+
+var (
+	errNoFileSelected = errors.New("no file selected")
+	errBinaryFile     = errors.New("binary file")
+	errFileTooLarge   = errors.New("file is too large")
+	errInvalidFile    = errors.New("invalid file")
+)
+
+// FileItemsMsg is a message that contains a list of files.
+type FileItemsMsg []selector.IdentifiableItem
+
+// FileContentMsg is a message that contains the content of a file.
+type FileContentMsg struct {
+	content string
+	ext     string
+}
+
+// Files is the model for the files view.
+type Files struct {
+	common         common.Common
+	selector       *selector.Selector
+	ref            *ggit.Reference
+	activeView     filesView
+	repo           git.GitRepo
+	code           *code.Code
+	path           string
+	currentItem    *FileItem
+	currentContent FileContentMsg
+	lastSelected   []int
+}
+
+// NewFiles creates a new files model.
+func NewFiles(common common.Common) *Files {
+	f := &Files{
+		common:       common,
+		code:         code.New(common, "", ""),
+		activeView:   filesViewFiles,
+		lastSelected: make([]int, 0),
+	}
+	selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{common.Styles})
+	selector.SetShowFilter(false)
+	selector.SetShowHelp(false)
+	selector.SetShowPagination(false)
+	selector.SetShowStatusBar(false)
+	selector.SetShowTitle(false)
+	selector.SetFilteringEnabled(false)
+	selector.DisableQuitKeybindings()
+	selector.KeyMap.NextPage = common.KeyMap.NextPage
+	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
+	f.selector = selector
+	return f
+}
+
+// SetSize implements common.Component.
+func (f *Files) SetSize(width, height int) {
+	f.common.SetSize(width, height)
+	f.selector.SetSize(width, height)
+	f.code.SetSize(width, height)
+}
+
+// Init implements tea.Model.
+func (f *Files) Init() tea.Cmd {
+	f.path = ""
+	f.currentItem = nil
+	f.activeView = filesViewFiles
+	f.lastSelected = make([]int, 0)
+	return f.updateFilesCmd
+}
+
+// Update implements tea.Model.
+func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		f.selector.Select(0)
+		f.repo = git.GitRepo(msg)
+		cmds = append(cmds, f.Init())
+	case RefMsg:
+		f.ref = msg
+		cmds = append(cmds, f.Init())
+	case FileItemsMsg:
+		cmds = append(cmds,
+			f.selector.SetItems(msg),
+			updateStatusBarCmd,
+		)
+	case FileContentMsg:
+		f.activeView = filesViewContent
+		f.currentContent = msg
+		f.code.SetContent(msg.content, msg.ext)
+		f.code.GotoTop()
+		cmds = append(cmds, updateStatusBarCmd)
+	case selector.SelectMsg:
+		switch sel := msg.IdentifiableItem.(type) {
+		case FileItem:
+			f.currentItem = &sel
+			f.path = filepath.Join(f.path, sel.entry.Name())
+			log.Printf("selected index %d", f.selector.Index())
+			if sel.entry.IsTree() {
+				cmds = append(cmds, f.selectTreeCmd)
+			} else {
+				cmds = append(cmds, f.selectFileCmd)
+			}
+		}
+	case tea.KeyMsg:
+		switch f.activeView {
+		case filesViewFiles:
+			switch msg.String() {
+			case "l", "right":
+				cmds = append(cmds, f.selector.SelectItem)
+			case "h", "left":
+				cmds = append(cmds, f.deselectItemCmd)
+			}
+		case filesViewContent:
+			switch msg.String() {
+			case "h", "left":
+				cmds = append(cmds, f.deselectItemCmd)
+			}
+		}
+	case tea.WindowSizeMsg:
+		if f.currentContent.content != "" {
+			m, cmd := f.code.Update(msg)
+			f.code = m.(*code.Code)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+		if f.repo != nil {
+			cmds = append(cmds, f.updateFilesCmd)
+		}
+	}
+	switch f.activeView {
+	case filesViewFiles:
+		m, cmd := f.selector.Update(msg)
+		f.selector = m.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case filesViewContent:
+		m, cmd := f.code.Update(msg)
+		f.code = m.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return f, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (f *Files) View() string {
+	switch f.activeView {
+	case filesViewFiles:
+		return f.selector.View()
+	case filesViewContent:
+		return f.code.View()
+	default:
+		return ""
+	}
+}
+
+// StatusBarValue returns the status bar value.
+func (f *Files) StatusBarValue() string {
+	p := f.path
+	if p == "." {
+		return ""
+	}
+	return p
+}
+
+// StatusBarInfo returns the status bar info.
+func (f *Files) StatusBarInfo() string {
+	switch f.activeView {
+	case filesViewFiles:
+		return fmt.Sprintf("%d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
+	case filesViewContent:
+		return fmt.Sprintf("%.f%%", f.code.ScrollPercent()*100)
+	default:
+		return ""
+	}
+}
+
+func (f *Files) updateFilesCmd() tea.Msg {
+	files := make([]selector.IdentifiableItem, 0)
+	dirs := make([]selector.IdentifiableItem, 0)
+	t, err := f.repo.Tree(f.ref, f.path)
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	ents, err := t.Entries()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	ents.Sort()
+	for _, e := range ents {
+		if e.IsTree() {
+			dirs = append(dirs, FileItem{e})
+		} else {
+			files = append(files, FileItem{e})
+		}
+	}
+	return FileItemsMsg(append(dirs, files...))
+}
+
+func (f *Files) selectTreeCmd() tea.Msg {
+	if f.currentItem != nil && f.currentItem.entry.IsTree() {
+		f.lastSelected = append(f.lastSelected, f.selector.Index())
+		f.selector.Select(0)
+		return f.updateFilesCmd()
+	}
+	return common.ErrorMsg(errNoFileSelected)
+}
+
+func (f *Files) selectFileCmd() tea.Msg {
+	i := f.currentItem
+	if i != nil && !i.entry.IsTree() {
+		fi := i.entry.File()
+		if i.Mode().IsDir() || f == nil {
+			return common.ErrorMsg(errInvalidFile)
+		}
+		bin, err := fi.IsBinary()
+		if err != nil {
+			f.path = filepath.Dir(f.path)
+			return common.ErrorMsg(err)
+		}
+		if bin {
+			f.path = filepath.Dir(f.path)
+			return common.ErrorMsg(errBinaryFile)
+		}
+		c, err := fi.Bytes()
+		if err != nil {
+			f.path = filepath.Dir(f.path)
+			return common.ErrorMsg(err)
+		}
+		f.lastSelected = append(f.lastSelected, f.selector.Index())
+		return FileContentMsg{string(c), i.entry.Name()}
+	}
+	return common.ErrorMsg(errNoFileSelected)
+}
+
+func (f *Files) deselectItemCmd() tea.Msg {
+	f.path = filepath.Dir(f.path)
+	f.activeView = filesViewFiles
+	msg := f.updateFilesCmd()
+	index := 0
+	if len(f.lastSelected) > 0 {
+		index = f.lastSelected[len(f.lastSelected)-1]
+		f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
+	}
+	log.Printf("deselect %d", index)
+	f.selector.Select(index)
+	return msg
+}

ui/pages/repo/filesitem.go 🔗

@@ -0,0 +1,119 @@
+package repo
+
+import (
+	"fmt"
+	"io"
+	"io/fs"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/dustin/go-humanize"
+)
+
+// FileItem is a list item for a file.
+type FileItem struct {
+	entry *git.TreeEntry
+}
+
+// ID returns the ID of the file item.
+func (i FileItem) ID() string {
+	return i.entry.Name()
+}
+
+// Title returns the title of the file item.
+func (i FileItem) Title() string {
+	return i.entry.Name()
+}
+
+// Description returns the description of the file item.
+func (i FileItem) Description() string {
+	return ""
+}
+
+// Mode returns the mode of the file item.
+func (i FileItem) Mode() fs.FileMode {
+	return i.entry.Mode()
+}
+
+// FilterValue implements list.Item.
+func (i FileItem) FilterValue() string { return i.Title() }
+
+// FileItems is a list of file items.
+type FileItems []FileItem
+
+// Len implements sort.Interface.
+func (cl FileItems) Len() int { return len(cl) }
+
+// Swap implements sort.Interface.
+func (cl FileItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+
+// Less implements sort.Interface.
+func (cl FileItems) Less(i, j int) bool {
+	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
+		return cl[i].Title() < cl[j].Title()
+	} else if cl[i].entry.IsTree() {
+		return true
+	} else if cl[j].entry.IsTree() {
+		return false
+	} else {
+		return cl[i].Title() < cl[j].Title()
+	}
+}
+
+// FileItemDelegate is the delegate for the file item list.
+type FileItemDelegate struct {
+	style *styles.Styles
+}
+
+// Height returns the height of the file item list. Implements list.ItemDelegate.
+func (d FileItemDelegate) Height() int { return 1 }
+
+// Spacing returns the spacing of the file item list. Implements list.ItemDelegate.
+func (d FileItemDelegate) Spacing() int { return 0 }
+
+// Update implements list.ItemDelegate.
+func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+
+// Render implements list.ItemDelegate.
+func (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	s := d.style
+	i, ok := listItem.(FileItem)
+	if !ok {
+		return
+	}
+
+	name := i.Title()
+	size := humanize.Bytes(uint64(i.entry.Size()))
+	if i.entry.IsTree() {
+		size = ""
+		name = s.TreeFileDir.Render(name)
+	}
+	var cs lipgloss.Style
+	mode := i.Mode()
+	if index == m.Index() {
+		cs = s.TreeItemActive
+		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
+	} else {
+		cs = s.TreeItemInactive
+		fmt.Fprint(w, s.TreeItemSelector.Render(" "))
+	}
+	leftMargin := s.TreeItemSelector.GetMarginLeft() +
+		s.TreeItemSelector.GetWidth() +
+		s.TreeFileMode.GetMarginLeft() +
+		s.TreeFileMode.GetWidth() +
+		cs.GetMarginLeft()
+	rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
+	name = truncateString(name, m.Width()-leftMargin-rightMargin, "…")
+	sizeStyle := s.TreeFileSize.Copy().
+		Width(m.Width() -
+			leftMargin -
+			s.TreeFileSize.GetMarginLeft() -
+			lipgloss.Width(name)).
+		Align(lipgloss.Right)
+	fmt.Fprint(w, s.TreeFileMode.Render(mode.String())+
+		cs.Render(name)+
+		sizeStyle.Render(size))
+}

ui/pages/repo/log.go 🔗

@@ -6,7 +6,6 @@ import (
 	"time"
 
 	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/glamour"
 	gansi "github.com/charmbracelet/glamour/ansi"
@@ -20,18 +19,18 @@ import (
 	"github.com/muesli/termenv"
 )
 
-type view int
+type logView int
 
 const (
-	logView view = iota
-	commitView
+	logViewCommits logView = iota
+	logViewDiff
 )
 
 // LogCountMsg is a message that contains the number of commits in a repo.
 type LogCountMsg int64
 
 // LogItemsMsg is a message that contains a slice of LogItem.
-type LogItemsMsg []list.Item
+type LogItemsMsg []selector.IdentifiableItem
 
 // LogCommitMsg is a message that contains a git commit.
 type LogCommitMsg *ggit.Commit
@@ -44,11 +43,12 @@ type Log struct {
 	common         common.Common
 	selector       *selector.Selector
 	vp             *viewport.Viewport
-	activeView     view
+	activeView     logView
 	repo           git.GitRepo
 	ref            *ggit.Reference
 	count          int64
 	nextPage       int
+	activeCommit   *ggit.Commit
 	selectedCommit *ggit.Commit
 	currentDiff    *ggit.Diff
 }
@@ -58,7 +58,7 @@ func NewLog(common common.Common) *Log {
 	l := &Log{
 		common:     common,
 		vp:         viewport.New(),
-		activeView: logView,
+		activeView: logViewCommits,
 	}
 	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{common.Styles})
 	selector.SetShowFilter(false)
@@ -84,7 +84,7 @@ func (l *Log) SetSize(width, height int) {
 // ShortHelp implements key.KeyMap.
 func (l *Log) ShortHelp() []key.Binding {
 	switch l.activeView {
-	case logView:
+	case logViewCommits:
 		return []key.Binding{
 			key.NewBinding(
 				key.WithKeys(
@@ -97,7 +97,7 @@ func (l *Log) ShortHelp() []key.Binding {
 				),
 			),
 		}
-	case commitView:
+	case logViewDiff:
 		return []key.Binding{
 			l.common.KeyMap.UpDown,
 			key.NewBinding(
@@ -143,9 +143,10 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		cmds = append(cmds, l.selector.SetItems(msg))
 		l.selector.SetPage(l.nextPage)
 		l.SetSize(l.common.Width, l.common.Height)
+		l.activeCommit = l.selector.SelectedItem().(LogItem).Commit
 	case tea.KeyMsg, tea.MouseMsg:
 		switch l.activeView {
-		case logView:
+		case logViewCommits:
 			switch key := msg.(type) {
 			case tea.KeyMsg:
 				switch key.String() {
@@ -164,15 +165,21 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, l.updateCommitsCmd)
 			}
 			cmds = append(cmds, cmd)
-		case commitView:
+		case logViewDiff:
 			switch key := msg.(type) {
 			case tea.KeyMsg:
 				switch key.String() {
 				case "h", "left":
-					l.activeView = logView
+					l.activeView = logViewCommits
 				}
 			}
 		}
+	case selector.ActiveMsg:
+		switch sel := msg.IdentifiableItem.(type) {
+		case LogItem:
+			l.activeCommit = sel.Commit
+		}
+		cmds = append(cmds, updateStatusBarCmd)
 	case selector.SelectMsg:
 		switch sel := msg.IdentifiableItem.(type) {
 		case LogItem:
@@ -191,7 +198,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			),
 		)
 		l.vp.GotoTop()
-		l.activeView = commitView
+		l.activeView = logViewDiff
 		cmds = append(cmds, updateStatusBarCmd)
 	case tea.WindowSizeMsg:
 		if l.selectedCommit != nil && l.currentDiff != nil {
@@ -208,7 +215,7 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	}
 	switch l.activeView {
-	case commitView:
+	case logViewDiff:
 		vp, cmd := l.vp.Update(msg)
 		l.vp = vp.(*viewport.Viewport)
 		if cmd != nil {
@@ -221,23 +228,36 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 // View implements tea.Model.
 func (l *Log) View() string {
 	switch l.activeView {
-	case logView:
+	case logViewCommits:
 		return l.selector.View()
-	case commitView:
+	case logViewDiff:
 		return l.vp.View()
 	default:
 		return ""
 	}
 }
 
+// StatusBarValue returns the status bar value.
+func (l *Log) StatusBarValue() string {
+	c := l.activeCommit
+	if c == nil {
+		return ""
+	}
+	return fmt.Sprintf("%s by %s on %s",
+		c.ID.String()[:7],
+		c.Author.Name,
+		c.Author.When.Format("02 Jan 2006"),
+	)
+}
+
 // StatusBarInfo returns the status bar info.
 func (l *Log) StatusBarInfo() string {
 	switch l.activeView {
-	case logView:
+	case logViewCommits:
 		// We're using l.nextPage instead of l.selector.Paginator.Page because
 		// of the paginator hack above.
 		return fmt.Sprintf("%d/%d", l.nextPage+1, l.selector.TotalPages())
-	case commitView:
+	case logViewDiff:
 		return fmt.Sprintf("%.f%%", l.vp.ScrollPercent()*100)
 	default:
 		return ""
@@ -262,7 +282,7 @@ func (l *Log) updateCommitsCmd() tea.Msg {
 			count = int64(msg)
 		}
 	}
-	items := make([]list.Item, count)
+	items := make([]selector.IdentifiableItem, count)
 	page := l.nextPage
 	limit := l.selector.PerPage()
 	skip := page * limit

ui/pages/repo/repo.go 🔗

@@ -45,6 +45,7 @@ type Repo struct {
 	statusbar    *statusbar.StatusBar
 	readme       *code.Code
 	log          *Log
+	files        *Files
 	ref          *ggit.Reference
 }
 
@@ -55,6 +56,7 @@ func New(common common.Common, rs git.GitRepoSource) *Repo {
 	readme := code.New(common, "", "")
 	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
 	log := NewLog(common)
+	files := NewFiles(common)
 	r := &Repo{
 		common:    common,
 		rs:        rs,
@@ -62,6 +64,7 @@ func New(common common.Common, rs git.GitRepoSource) *Repo {
 		statusbar: sb,
 		readme:    readme,
 		log:       log,
+		files:     files,
 	}
 	return r
 }
@@ -79,6 +82,7 @@ func (r *Repo) SetSize(width, height int) {
 	r.statusbar.SetSize(width, height-hm)
 	r.readme.SetSize(width, height-hm)
 	r.log.SetSize(width, height-hm)
+	r.files.SetSize(width, height-hm)
 }
 
 // ShortHelp implements help.KeyMap.
@@ -127,25 +131,16 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			r.tabs.Init(),
 			r.updateReadmeCmd,
 			r.updateRefCmd,
+			r.updateModels(msg),
 		)
-		// Pass msg to log.
-		l, cmd := r.log.Update(msg)
-		r.log = l.(*Log)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds,
 			r.updateStatusBarCmd,
 			r.log.Init(),
+			r.files.Init(),
+			r.updateModels(msg),
 		)
-		// Pass msg to log.
-		l, cmd := r.log.Update(msg)
-		r.log = l.(*Log)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
 	case tabs.ActiveTabMsg:
 		r.activeTab = tab(msg)
 		if r.selectedRepo != nil {
@@ -155,6 +150,12 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if r.selectedRepo != nil {
 			cmds = append(cmds, r.updateStatusBarCmd)
 		}
+	case FileItemsMsg:
+		f, cmd := r.files.Update(msg)
+		r.files = f.(*Files)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case LogCountMsg, LogItemsMsg:
 		l, cmd := r.log.Update(msg)
 		r.log = l.(*Log)
@@ -169,11 +170,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
-		l, cmd := r.log.Update(msg)
-		r.log = l.(*Log)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
+		cmds = append(cmds, r.updateModels(msg))
 	}
 	t, cmd := r.tabs.Update(msg)
 	r.tabs = t.(*tabs.Tabs)
@@ -193,6 +190,11 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 	case filesTab:
+		f, cmd := r.files.Update(msg)
+		r.files = f.(*Files)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case commitsTab:
 		l, cmd := r.log.Update(msg)
 		r.log = l.(*Log)
@@ -219,18 +221,21 @@ func (r *Repo) View() string {
 		r.common.Styles.Tabs.GetVerticalFrameSize()
 	mainStyle := repoBodyStyle.
 		Height(r.common.Height - hm)
-	main := mainStyle.Render("")
+	main := ""
 	switch r.activeTab {
 	case readmeTab:
-		main = mainStyle.Render(r.readme.View())
+		main = r.readme.View()
 	case filesTab:
+		main = r.files.View()
 	case commitsTab:
-		main = mainStyle.Render(r.log.View())
+		main = r.log.View()
+	case branchesTab:
+	case tagsTab:
 	}
 	view := lipgloss.JoinVertical(lipgloss.Top,
 		r.headerView(),
 		r.tabs.View(),
-		main,
+		mainStyle.Render(main),
 		r.statusbar.View(),
 	)
 	return s.Render(view)
@@ -242,7 +247,11 @@ func (r *Repo) headerView() string {
 	}
 	name := r.common.Styles.RepoHeaderName.Render(r.selectedItem.Title())
 	// TODO move this into a style.
-	url := lipgloss.NewStyle().MarginLeft(2).Render(r.selectedItem.URL())
+	url := lipgloss.NewStyle().
+		MarginLeft(1).
+		Width(r.common.Width - lipgloss.Width(name) - 1).
+		Align(lipgloss.Right).
+		Render(r.selectedItem.URL())
 	desc := r.common.Styles.RepoHeaderDesc.Render(r.selectedItem.Description())
 	style := r.common.Styles.RepoHeader.Copy().Width(r.common.Width)
 	return style.Render(
@@ -268,16 +277,21 @@ func (r *Repo) setRepoCmd(repo string) tea.Cmd {
 }
 
 func (r *Repo) updateStatusBarCmd() tea.Msg {
+	value := ""
 	info := ""
 	switch r.activeTab {
 	case readmeTab:
 		info = fmt.Sprintf("%.f%%", r.readme.ScrollPercent()*100)
 	case commitsTab:
+		value = r.log.StatusBarValue()
 		info = r.log.StatusBarInfo()
+	case filesTab:
+		value = r.files.StatusBarValue()
+		info = r.files.StatusBarInfo()
 	}
 	return statusbar.StatusBarMsg{
 		Key:    r.selectedRepo.Name(),
-		Value:  "",
+		Value:  value,
 		Info:   info,
 		Branch: fmt.Sprintf(" %s", r.ref.Name().Short()),
 	}
@@ -299,6 +313,21 @@ func (r *Repo) updateRefCmd() tea.Msg {
 	return RefMsg(head)
 }
 
+func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
+	cmds := make([]tea.Cmd, 0)
+	l, cmd := r.log.Update(msg)
+	r.log = l.(*Log)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	f, cmd := r.files.Update(msg)
+	r.files = f.(*Files)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	return tea.Batch(cmds...)
+}
+
 func updateStatusBarCmd() tea.Msg {
 	return UpdateStatusBarMsg{}
 }

ui/pages/selection/selection.go 🔗

@@ -5,7 +5,6 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/bubbles/key"
-	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	appCfg "github.com/charmbracelet/soft-serve/config"
@@ -130,7 +129,7 @@ func (s *Selection) Init() tea.Cmd {
 		pty, _, _ := session.Pty()
 		environ = append(environ, fmt.Sprintf("TERM=%s", pty.Term))
 	}
-	items := make([]list.Item, 0)
+	items := make([]selector.IdentifiableItem, 0)
 	cfg := s.s.Config()
 	// TODO clean up this and move style to its own var.
 	yank := func(text string) *yankable.Yankable {

ui/ui.go 🔗

@@ -120,6 +120,7 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
+		ui.SetSize(msg.Width, msg.Height)
 		h, cmd := ui.header.Update(msg)
 		ui.header = h.(*header.Header)
 		if cmd != nil {
@@ -137,7 +138,6 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, cmd)
 			}
 		}
-		ui.SetSize(msg.Width, msg.Height)
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
@@ -161,10 +161,12 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		}
 	}
-	m, cmd := ui.pages[ui.activePage].Update(msg)
-	ui.pages[ui.activePage] = m.(common.Page)
-	if cmd != nil {
-		cmds = append(cmds, cmd)
+	if ui.state == loadedState {
+		m, cmd := ui.pages[ui.activePage].Update(msg)
+		ui.pages[ui.activePage] = m.(common.Page)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	}
 	return ui, tea.Batch(cmds...)
 }