Detailed changes
  
  
    
    @@ -1,4 +1,4 @@
-package tui
+package git
 
 import (
 	"log"
@@ -11,21 +11,29 @@ import (
 	"github.com/go-git/go-git/v5/plumbing/object"
 )
 
-type commitLog []*object.Commit
+type RepoCommit struct {
+	Name   string
+	Repo   *git.Repository
+	Commit *object.Commit
+}
+
+type CommitLog []RepoCommit
 
-func (cl commitLog) Len() int           { return len(cl) }
-func (cl commitLog) Swap(i, j int)      { cl[i], cl[j] = cl[j], cl[i] }
-func (cl commitLog) Less(i, j int) bool { return cl[i].Author.When.After(cl[j].Author.When) }
+func (cl CommitLog) Len() int      { return len(cl) }
+func (cl CommitLog) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+func (cl CommitLog) Less(i, j int) bool {
+	return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)
+}
 
-type repoSource struct {
+type RepoSource struct {
 	mtx     sync.Mutex
 	path    string
 	repos   []*git.Repository
-	commits commitLog
+	commits CommitLog
 }
 
-func newRepoSource(repoPath string) *repoSource {
-	rs := &repoSource{path: repoPath}
+func NewRepoSource(repoPath string) *RepoSource {
+	rs := &RepoSource{path: repoPath}
 	go func() {
 		for {
 			rs.loadRepos()
@@ -35,13 +43,13 @@ func newRepoSource(repoPath string) *repoSource {
 	return rs
 }
 
-func (rs *repoSource) allRepos() []*git.Repository {
+func (rs *RepoSource) AllRepos() []*git.Repository {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
 	return rs.repos
 }
 
-func (rs *repoSource) getCommits(limit int) []*object.Commit {
+func (rs *RepoSource) GetCommits(limit int) []RepoCommit {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
 	if limit > len(rs.commits) {
@@ -50,7 +58,7 @@ func (rs *repoSource) getCommits(limit int) []*object.Commit {
 	return rs.commits[:limit]
 }
 
-func (rs *repoSource) loadRepos() {
+func (rs *RepoSource) loadRepos() {
 	rs.mtx.Lock()
 	defer rs.mtx.Unlock()
 	rd, err := os.ReadDir(rs.path)
@@ -58,7 +66,7 @@ func (rs *repoSource) loadRepos() {
 		return
 	}
 	rs.repos = make([]*git.Repository, 0)
-	rs.commits = make([]*object.Commit, 0)
+	rs.commits = make([]RepoCommit, 0)
 	for _, rd := range rd {
 		r, err := git.PlainOpen(rs.path + string(os.PathSeparator) + rd.Name())
 		if err != nil {
@@ -69,7 +77,7 @@ func (rs *repoSource) loadRepos() {
 			log.Fatal(err)
 		}
 		l.ForEach(func(c *object.Commit) error {
-			rs.commits = append(rs.commits, c)
+			rs.commits = append(rs.commits, RepoCommit{Name: rd.Name(), Repo: r, Commit: c})
 			return nil
 		})
 		sort.Sort(rs.commits)
  
  
  
    
    @@ -7,9 +7,11 @@ replace github.com/charmbracelet/charm => ../charm
 replace github.com/charmbracelet/bubbletea => ../bubbletea
 
 require (
+	github.com/charmbracelet/bubbles v0.8.0
 	github.com/charmbracelet/bubbletea v0.14.0
 	github.com/charmbracelet/charm v0.8.6
 	github.com/charmbracelet/lipgloss v0.2.1
+	github.com/dustin/go-humanize v1.0.0
 	github.com/gliderlabs/ssh v0.3.3
 	github.com/go-git/go-git/v5 v5.4.2
 	github.com/meowgorithm/babyenv v1.3.0
  
  
  
    
    @@ -23,6 +23,7 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
 github.com/calmh/randomart v1.1.0/go.mod h1:DQUbPVyP+7PAs21w/AnfMKG5NioxS3TbZ2F9MSK/jFM=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
+github.com/charmbracelet/bubbles v0.8.0 h1:+l2op90Ag37Vn+30O1hbg/0wBl+e+sxHhgY1F/rvdHs=
 github.com/charmbracelet/bubbles v0.8.0/go.mod h1:5WX1sSSjNCgCrzvRMN/z23HxvWaa+AI16Ch0KPZPeDs=
 github.com/charmbracelet/bubbletea v0.13.1/go.mod h1:tp9tr9Dadh0PLhgiwchE5zZJXm5543JYjHG9oY+5qSg=
 github.com/charmbracelet/bubbletea v0.14.0 h1:SBbtRPkZ/wGYyTAn2zrBERes/nwzxZR5QdJ5nvczmFw=
@@ -53,6 +54,7 @@ github.com/dgraph-io/ristretto v0.0.4-0.20210122082011-bb5d392ed82d/go.mod h1:tv
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
 github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
 github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
  
  
  
    
    @@ -2,10 +2,11 @@ package tui
 
 import (
 	"fmt"
+	"smoothie/git"
+	"smoothie/tui/bubbles/commits"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/gliderlabs/ssh"
-	"github.com/go-git/go-git/v5/plumbing/object"
 )
 
 type sessionState int
@@ -13,7 +14,7 @@ type sessionState int
 const (
 	startState sessionState = iota
 	errorState
-	commitsLoadedState
+	loadedState
 	quittingState
 	quitState
 )
@@ -26,45 +27,44 @@ func (e errMsg) Error() string {
 	return e.err.Error()
 }
 
-func SessionHandler(repoPath string) func(ssh.Session) (tea.Model, []tea.ProgramOption) {
-	rs := newRepoSource(repoPath)
+func SessionHandler(reposPath string) func(ssh.Session) (tea.Model, []tea.ProgramOption) {
+	rs := git.NewRepoSource(reposPath)
 	return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
 		if len(s.Command()) == 0 {
 			pty, changes, active := s.Pty()
 			if !active {
 				return nil, nil
 			}
-			return NewModel(pty.Window.Width, pty.Window.Height, changes, rs), nil
+			return NewModel(pty.Window.Width, pty.Window.Height, changes, rs), []tea.ProgramOption{tea.WithAltScreen()}
 		}
 		return nil, nil
 	}
 }
 
 type Model struct {
-	state         sessionState
-	error         string
-	info          string
-	width         int
-	height        int
-	windowChanges <-chan ssh.Window
-	repos         *repoSource
-	commits       []*object.Commit
+	state          sessionState
+	error          string
+	info           string
+	width          int
+	height         int
+	windowChanges  <-chan ssh.Window
+	repoSource     *git.RepoSource
+	commitTimeline *commits.Bubble
 }
 
-func NewModel(width int, height int, windowChanges <-chan ssh.Window, repos *repoSource) *Model {
+func NewModel(width int, height int, windowChanges <-chan ssh.Window, repoSource *git.RepoSource) *Model {
 	m := &Model{
 		width:         width,
 		height:        height,
 		windowChanges: windowChanges,
-		repos:         repos,
-		commits:       make([]*object.Commit, 0),
+		repoSource:    repoSource,
 	}
 	m.state = startState
 	return m
 }
 
 func (m *Model) Init() tea.Cmd {
-	return tea.Batch(m.windowChangesCmd, tea.HideCursor, m.getCommitsCmd)
+	return tea.Batch(m.windowChangesCmd, m.getCommitsCmd)
 }
 
 func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -77,6 +77,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.String() {
 		case "q", "ctrl+c":
 			return m, tea.Quit
+		case "j", "k", "up", "down":
+			_, cmd := m.commitTimeline.Update(msg)
+			cmds = append(cmds, cmd)
 		}
 	case errMsg:
 		m.error = msg.Error()
@@ -89,6 +92,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.WindowSizeMsg:
 		m.width = msg.Width
 		m.height = msg.Height
+		m.commitTimeline.Height = msg.Height
 	}
 	return m, tea.Batch(cmds...)
 }
@@ -100,13 +104,8 @@ func (m *Model) View() string {
 	s := ""
 	content := ""
 	switch m.state {
-	case startState:
-		s += normalStyle.Render("Loading")
-	case commitsLoadedState:
-		for _, c := range m.commits {
-			msg := fmt.Sprintf("%s %s %s %s", c.Author.When, c.Author.Name, c.Author.Email, c.Message)
-			s += normalStyle.Render(msg) + "\n"
-		}
+	case loadedState:
+		s += m.commitTimeline.View()
 	case errorState:
 		s += errorStyle.Render(fmt.Sprintf("Bummer: %s", m.error))
 	default:
  
  
  
    
    @@ -0,0 +1,70 @@
+package commits
+
+import (
+	"smoothie/git"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/dustin/go-humanize"
+)
+
+type Bubble struct {
+	Commits  []git.RepoCommit
+	Margin   int
+	Width    int
+	Height   int
+	viewport viewport.Model
+}
+
+func NewBubble(height int, margin int, width int, rcs []git.RepoCommit) *Bubble {
+	b := &Bubble{
+		Commits:  rcs,
+		viewport: viewport.Model{Height: height - margin, Width: width},
+	}
+	s := ""
+	for _, rc := range rcs {
+		s += b.renderCommit(rc) + "\n"
+	}
+	b.viewport.SetContent(s)
+	return b
+}
+
+func (b *Bubble) Init() tea.Cmd {
+	return nil
+}
+
+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		b.viewport.Height = msg.Height - b.Margin
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "up", "k":
+			b.viewport.LineUp(1)
+		case "down", "j":
+			b.viewport.LineDown(1)
+		}
+	}
+	return b, tea.Batch(cmds...)
+}
+
+func (b *Bubble) renderCommit(rc git.RepoCommit) string {
+	s := ""
+	s += commitRepoNameStyle.Render(rc.Name)
+	s += " "
+	s += commitDateStyle.Render(humanize.Time(rc.Commit.Author.When))
+	s += "\n"
+	s += commitCommentStyle.Render(strings.TrimSpace(rc.Commit.Message))
+	s += "\n"
+	s += commitAuthorStyle.Render(rc.Commit.Author.Name)
+	s += " "
+	s += commitAuthorEmailStyle.Render(rc.Commit.Author.Email)
+	s += " "
+	return commitBoxStyle.Width(b.viewport.Height).Render(s)
+}
+
+func (b *Bubble) View() string {
+	return b.viewport.View()
+}
  
  
  
    
    @@ -0,0 +1,26 @@
+package commits
+
+import (
+	"github.com/charmbracelet/lipgloss"
+)
+
+var commitBoxStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#FFFFFF")).
+	BorderStyle(lipgloss.RoundedBorder()).
+	BorderForeground(lipgloss.Color("#670083")).
+	Padding(1)
+var commitRepoNameStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#8922A5"))
+var commitAuthorStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#670083"))
+var commitAuthorEmailStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#781194"))
+var commitDateStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#781194"))
+var commitCommentStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#A0A0A0")).
+	BorderStyle(lipgloss.Border{Left: ">"}).
+	BorderForeground(lipgloss.Color("#606060")).
+	PaddingLeft(1).
+	PaddingBottom(0).
+	Margin(0)
  
  
  
    
    @@ -0,0 +1,22 @@
+package repo
+
+import (
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/go-git/go-git/v5"
+)
+
+type Bubble struct {
+	repo *git.Repository
+}
+
+func (b *Bubble) Init() tea.Cmd {
+	return nil
+}
+
+func (b *Bubble) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return nil, nil
+}
+
+func (b *Bubble) View() string {
+	return "repo"
+}
  
  
  
    
    @@ -0,0 +1,25 @@
+package repo
+
+import (
+	"github.com/charmbracelet/lipgloss"
+)
+
+var commitBoxStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#FFFFFF")).
+	BorderStyle(lipgloss.RoundedBorder()).
+	BorderForeground(lipgloss.Color("#670083")).
+	Padding(1)
+var commitRepoNameStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#8922A5"))
+var commitAuthorStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#670083"))
+var commitAuthorEmailStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#781194"))
+var commitDateStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#781194"))
+var commitCommentStyle = lipgloss.NewStyle().
+	Foreground(lipgloss.Color("#606060")).
+	BorderStyle(lipgloss.Border{Left: ">"}).
+	PaddingLeft(1).
+	PaddingBottom(0).
+	Margin(0)
  
  
  
    
    @@ -1,6 +1,8 @@
 package tui
 
 import (
+	"smoothie/tui/bubbles/commits"
+
 	tea "github.com/charmbracelet/bubbletea"
 )
 
@@ -14,7 +16,7 @@ func (m *Model) windowChangesCmd() tea.Msg {
 }
 
 func (m *Model) getCommitsCmd() tea.Msg {
-	m.commits = m.repos.getCommits(20)
-	m.state = commitsLoadedState
+	m.commitTimeline = commits.NewBubble(m.height, 2, 80, m.repoSource.GetCommits(200))
+	m.state = loadedState
 	return nil
 }