feat: add refs component

Ayman Bagabas created

Change summary

ui/components/statusbar/statusbar.go |   5 
ui/components/tabs/tabs.go           |  10 +
ui/pages/repo/log.go                 |   4 
ui/pages/repo/refs.go                | 126 +++++++++++++++++++++++
ui/pages/repo/refsitem.go            |  75 ++++++++++++++
ui/pages/repo/repo.go                | 160 ++++++++++++++---------------
6 files changed, 298 insertions(+), 82 deletions(-)

Detailed changes

ui/components/statusbar/statusbar.go 🔗

@@ -18,6 +18,11 @@ type StatusBar struct {
 	msg    StatusBarMsg
 }
 
+type Model interface {
+	StatusBarValue() string
+	StatusBarInfo() string
+}
+
 func New(c common.Common) *StatusBar {
 	s := &StatusBar{
 		common: c,

ui/components/tabs/tabs.go 🔗

@@ -7,6 +7,8 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/common"
 )
 
+type SelectTabMsg int
+
 type ActiveTabMsg int
 
 type Tabs struct {
@@ -45,6 +47,8 @@ func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs)
 			cmds = append(cmds, t.activeTabCmd)
 		}
+	case SelectTabMsg:
+		t.activeTab = int(msg)
 	}
 	return t, tea.Batch(cmds...)
 }
@@ -68,3 +72,9 @@ func (t *Tabs) View() string {
 func (t *Tabs) activeTabCmd() tea.Msg {
 	return ActiveTabMsg(t.activeTab)
 }
+
+func SelectTabCmd(tab int) tea.Cmd {
+	return func() tea.Msg {
+		return SelectTabMsg(tab)
+	}
+}

ui/pages/repo/log.go 🔗

@@ -116,6 +116,10 @@ func (l *Log) ShortHelp() []key.Binding {
 	}
 }
 
+func (l Log) FullHelp() [][]key.Binding {
+	return [][]key.Binding{}
+}
+
 // Init implements tea.Model.
 func (l *Log) Init() tea.Cmd {
 	cmds := make([]tea.Cmd, 0)

ui/pages/repo/refs.go 🔗

@@ -0,0 +1,126 @@
+package repo
+
+import (
+	"sort"
+	"strings"
+
+	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/selector"
+	"github.com/charmbracelet/soft-serve/ui/components/tabs"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+type RefItemsMsg struct {
+	prefix string
+	items  []selector.IdentifiableItem
+}
+
+type Refs struct {
+	common    common.Common
+	selector  *selector.Selector
+	repo      git.GitRepo
+	ref       *ggit.Reference
+	refPrefix string
+}
+
+func NewRefs(common common.Common, refPrefix string) *Refs {
+	r := &Refs{
+		common:    common,
+		refPrefix: refPrefix,
+	}
+	s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{common.Styles})
+	s.SetShowFilter(false)
+	s.SetShowHelp(false)
+	s.SetShowPagination(true)
+	s.SetShowStatusBar(false)
+	s.SetShowTitle(false)
+	s.SetFilteringEnabled(false)
+	s.DisableQuitKeybindings()
+	r.selector = s
+	return r
+}
+
+func (r *Refs) SetSize(width, height int) {
+	r.common.SetSize(width, height)
+	r.selector.SetSize(width, height)
+}
+
+func (r *Refs) Init() tea.Cmd {
+	return r.updateItemsCmd
+}
+
+func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		r.selector.Select(0)
+		r.repo = git.GitRepo(msg)
+		cmds = append(cmds, r.Init())
+	case RefMsg:
+		r.ref = msg
+		cmds = append(cmds, r.Init())
+	case RefItemsMsg:
+		cmds = append(cmds, r.selector.SetItems(msg.items))
+	case selector.SelectMsg:
+		switch i := msg.IdentifiableItem.(type) {
+		case RefItem:
+			cmds = append(cmds,
+				switchRefCmd(i.Reference),
+				tabs.SelectTabCmd(int(filesTab)),
+			)
+		}
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "l", "right":
+			cmds = append(cmds, r.selector.SelectItem)
+		}
+	}
+	m, cmd := r.selector.Update(msg)
+	r.selector = m.(*selector.Selector)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	return r, tea.Batch(cmds...)
+}
+
+func (r *Refs) View() string {
+	return r.selector.View()
+}
+
+func (r *Refs) StatusBarValue() string {
+	return ""
+}
+
+func (r *Refs) StatusBarInfo() string {
+	return ""
+}
+
+func (r *Refs) updateItemsCmd() tea.Msg {
+	its := make(RefItems, 0)
+	refs, err := r.repo.References()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	for _, ref := range refs {
+		if strings.HasPrefix(ref.Name().String(), r.refPrefix) {
+			its = append(its, RefItem{ref})
+		}
+	}
+	sort.Sort(its)
+	items := make([]selector.IdentifiableItem, len(its))
+	for i, it := range its {
+		items[i] = it
+	}
+	return RefItemsMsg{
+		items:  items,
+		prefix: r.refPrefix,
+	}
+}
+
+func switchRefCmd(ref *ggit.Reference) tea.Cmd {
+	return func() tea.Msg {
+		return RefMsg(ref)
+	}
+}

ui/pages/repo/refsitem.go 🔗

@@ -0,0 +1,75 @@
+package repo
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/tui/common"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+)
+
+type RefItem struct {
+	*git.Reference
+}
+
+func (i RefItem) ID() string {
+	return i.Reference.Name().String()
+}
+
+func (i RefItem) Title() string {
+	return i.Reference.Name().Short()
+}
+
+func (i RefItem) Description() string {
+	return ""
+}
+
+func (i RefItem) Short() string {
+	return i.Reference.Name().Short()
+}
+
+func (i RefItem) FilterValue() string { return i.Short() }
+
+type RefItems []RefItem
+
+func (cl RefItems) Len() int      { return len(cl) }
+func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+func (cl RefItems) Less(i, j int) bool {
+	return cl[i].Short() < cl[j].Short()
+}
+
+type RefItemDelegate struct {
+	style *styles.Styles
+}
+
+func (d RefItemDelegate) Height() int                               { return 1 }
+func (d RefItemDelegate) Spacing() int                              { return 0 }
+func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	s := d.style
+	i, ok := listItem.(RefItem)
+	if !ok {
+		return
+	}
+
+	ref := i.Short()
+	if i.Reference.IsTag() {
+		ref = s.RefItemTag.Render(ref)
+	}
+	ref = s.RefItemBranch.Render(ref)
+	refMaxWidth := m.Width() -
+		s.RefItemSelector.GetMarginLeft() -
+		s.RefItemSelector.GetWidth() -
+		s.RefItemInactive.GetMarginLeft()
+	ref = common.TruncateString(ref, refMaxWidth, "…")
+	if index == m.Index() {
+		fmt.Fprint(w, s.RefItemSelector.Render(">")+
+			s.RefItemActive.Render(ref))
+	} else {
+		fmt.Fprint(w, s.LogItemSelector.Render(" ")+
+			s.RefItemInactive.Render(ref))
+	}
+}

ui/pages/repo/repo.go 🔗

@@ -3,6 +3,7 @@ package repo
 import (
 	"fmt"
 
+	"github.com/charmbracelet/bubbles/help"
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
@@ -26,6 +27,7 @@ const (
 	tagsTab
 )
 
+// UpdateStatusBarMsg updates the status bar.
 type UpdateStatusBarMsg struct{}
 
 // RepoMsg is a message that contains a git.Repository.
@@ -43,28 +45,33 @@ type Repo struct {
 	activeTab    tab
 	tabs         *tabs.Tabs
 	statusbar    *statusbar.StatusBar
-	readme       *code.Code
-	log          *Log
-	files        *Files
+	boxes        []common.Component
 	ref          *ggit.Reference
 }
 
 // New returns a new Repo.
-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, "", "")
+func New(c common.Common, rs git.GitRepoSource) *Repo {
+	sb := statusbar.New(c)
+	tb := tabs.New(c, []string{"Readme", "Files", "Commits", "Branches", "Tags"})
+	readme := code.New(c, "", "")
 	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
-	log := NewLog(common)
-	files := NewFiles(common)
+	log := NewLog(c)
+	files := NewFiles(c)
+	branches := NewRefs(c, ggit.RefsHeads)
+	tags := NewRefs(c, ggit.RefsTags)
+	boxes := []common.Component{
+		readme,
+		files,
+		log,
+		branches,
+		tags,
+	}
 	r := &Repo{
-		common:    common,
+		common:    c,
 		rs:        rs,
 		tabs:      tb,
 		statusbar: sb,
-		readme:    readme,
-		log:       log,
-		files:     files,
+		boxes:     boxes,
 	}
 	return r
 }
@@ -80,9 +87,9 @@ func (r *Repo) SetSize(width, height int) {
 		r.common.Styles.Tabs.GetVerticalFrameSize()
 	r.tabs.SetSize(width, height-hm)
 	r.statusbar.SetSize(width, height-hm)
-	r.readme.SetSize(width, height-hm)
-	r.log.SetSize(width, height-hm)
-	r.files.SetSize(width, height-hm)
+	for _, b := range r.boxes {
+		b.SetSize(width, height-hm)
+	}
 }
 
 // ShortHelp implements help.KeyMap.
@@ -98,7 +105,7 @@ func (r *Repo) ShortHelp() []key.Binding {
 	case readmeTab:
 		b = append(b, r.common.KeyMap.UpDown)
 	case commitsTab:
-		b = append(b, r.log.ShortHelp()...)
+		b = append(b, r.boxes[commitsTab].(help.KeyMap).ShortHelp()...)
 	}
 	return b
 }
@@ -126,7 +133,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case RepoMsg:
 		r.activeTab = 0
 		r.selectedRepo = git.GitRepo(msg)
-		r.readme.GotoTop()
+		r.boxes[readmeTab].(*code.Code).GotoTop()
 		cmds = append(cmds,
 			r.tabs.Init(),
 			r.updateReadmeCmd,
@@ -135,74 +142,80 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		)
 	case RefMsg:
 		r.ref = msg
+		for _, b := range r.boxes {
+			cmds = append(cmds, b.Init())
+		}
 		cmds = append(cmds,
 			r.updateStatusBarCmd,
-			r.log.Init(),
-			r.files.Init(),
 			r.updateModels(msg),
 		)
+	case tabs.SelectTabMsg:
+		r.activeTab = tab(msg)
+		t, cmd := r.tabs.Update(msg)
+		r.tabs = t.(*tabs.Tabs)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	case tabs.ActiveTabMsg:
 		r.activeTab = tab(msg)
 		if r.selectedRepo != nil {
 			cmds = append(cmds, r.updateStatusBarCmd)
 		}
 	case tea.KeyMsg, tea.MouseMsg:
+		t, cmd := r.tabs.Update(msg)
+		r.tabs = t.(*tabs.Tabs)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 		if r.selectedRepo != nil {
 			cmds = append(cmds, r.updateStatusBarCmd)
 		}
 	case FileItemsMsg:
-		f, cmd := r.files.Update(msg)
-		r.files = f.(*Files)
+		f, cmd := r.boxes[filesTab].Update(msg)
+		r.boxes[filesTab] = f.(*Files)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 	case LogCountMsg, LogItemsMsg:
-		l, cmd := r.log.Update(msg)
-		r.log = l.(*Log)
+		l, cmd := r.boxes[commitsTab].Update(msg)
+		r.boxes[commitsTab] = l.(*Log)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+	case RefItemsMsg:
+		switch msg.prefix {
+		case ggit.RefsHeads:
+			b, cmd := r.boxes[branchesTab].Update(msg)
+			r.boxes[branchesTab] = b.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		case ggit.RefsTags:
+			t, cmd := r.boxes[tagsTab].Update(msg)
+			r.boxes[tagsTab] = t.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	case UpdateStatusBarMsg:
 		cmds = append(cmds, r.updateStatusBarCmd)
 	case tea.WindowSizeMsg:
-		b, cmd := r.readme.Update(msg)
-		r.readme = b.(*code.Code)
+		b, cmd := r.boxes[readmeTab].Update(msg)
+		r.boxes[readmeTab] = b.(*code.Code)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 		cmds = append(cmds, r.updateModels(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:
-		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)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	case branchesTab:
-	case tagsTab:
+	m, cmd := r.boxes[r.activeTab].Update(msg)
+	r.boxes[r.activeTab] = m.(common.Component)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
 	}
 	return r, tea.Batch(cmds...)
 }
@@ -221,17 +234,7 @@ func (r *Repo) View() string {
 		r.common.Styles.Tabs.GetVerticalFrameSize()
 	mainStyle := repoBodyStyle.
 		Height(r.common.Height - hm)
-	main := ""
-	switch r.activeTab {
-	case readmeTab:
-		main = r.readme.View()
-	case filesTab:
-		main = r.files.View()
-	case commitsTab:
-		main = r.log.View()
-	case branchesTab:
-	case tagsTab:
-	}
+	main := r.boxes[r.activeTab].View()
 	view := lipgloss.JoinVertical(lipgloss.Top,
 		r.headerView(),
 		r.tabs.View(),
@@ -277,17 +280,13 @@ func (r *Repo) setRepoCmd(repo string) tea.Cmd {
 }
 
 func (r *Repo) updateStatusBarCmd() tea.Msg {
-	value := ""
-	info := ""
+	var info, value string
 	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()
+		info = fmt.Sprintf("%.f%%", r.boxes[readmeTab].(*code.Code).ScrollPercent()*100)
+	default:
+		value = r.boxes[r.activeTab].(statusbar.Model).StatusBarValue()
+		info = r.boxes[r.activeTab].(statusbar.Model).StatusBarInfo()
 	}
 	return statusbar.StatusBarMsg{
 		Key:    r.selectedRepo.Name(),
@@ -302,7 +301,7 @@ func (r *Repo) updateReadmeCmd() tea.Msg {
 		return common.ErrorCmd(git.ErrMissingRepo)
 	}
 	rm, rp := r.selectedRepo.Readme()
-	return r.readme.SetContent(rm, rp)
+	return r.boxes[readmeTab].(*code.Code).SetContent(rm, rp)
 }
 
 func (r *Repo) updateRefCmd() tea.Msg {
@@ -315,15 +314,12 @@ func (r *Repo) updateRefCmd() tea.Msg {
 
 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)
+	for i, b := range r.boxes {
+		m, cmd := b.Update(msg)
+		r.boxes[i] = m.(common.Component)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
 	}
 	return tea.Batch(cmds...)
 }