feat: new git tui

Ayman Bagabas created

* Separate tui bubbles based on functionality
* Wrap gitui bubble in another bubble
* Move readme bubble to git/about
* Add tree/log/refs bubbles
* View changes from a specific branch
* Browse git tree and logs based on selected branch
* Display commit message, stats, and patch
* Display selected branch in footer
* Display file size and permissions in tree
* Omit displaying binary and large files in tree
* Omit displaying long diffs in log
* Use reflow to wrap and truncate long strings
* Sort tree files by name
* Sort refs by name
* Sort log by date

git/tree:
* Use bubbles/list to list tree files
* Display file size, mode, and title
* Use bubbles/viewport to display file content
* Omit displaying binary and large files
* Use glamour/ansi to render and highlight file contents
* Use go-humanize to display file size

git/refs:
* Use bubbles/list to display repository branches
* Propagate selected branch to parent bubble

git/log:
* Display branch commit log
* Truncate long commit titles
* Use bubbles/list to display commits
* Use bubbles/viewport to view commit
* Display commit author, date, hash, and message
* Display commit diff
* Use glamour/ansi to highlight commit diff
* Use go-humanize to display commit stat summary

git/types:
* Define constants
* Define interfaces
* Helper functions

Change summary

go.mod                                              |  10 
go.sum                                              |  17 
internal/git/git.go                                 |  28 
internal/tui/bubble.go                              |  64 +-
internal/tui/bubbles/commits/bubble.go              |  67 --
internal/tui/bubbles/commits/style.go               |  26 
internal/tui/bubbles/git/about/bubble.go            | 121 ++++
internal/tui/bubbles/git/bubble.go                  | 157 +++++
internal/tui/bubbles/git/log/bubble.go              | 394 +++++++++++++++
internal/tui/bubbles/git/refs/bubble.go             | 174 ++++++
internal/tui/bubbles/git/tree/bubble.go             | 355 +++++++++++++
internal/tui/bubbles/git/types/consts.go            |  28 +
internal/tui/bubbles/git/types/error.go             |  36 +
internal/tui/bubbles/git/types/formatter.go         |  36 +
internal/tui/bubbles/git/types/git.go               |  29 +
internal/tui/bubbles/git/types/help.go              |  10 
internal/tui/bubbles/git/types/utils.go             |  17 
internal/tui/bubbles/git/viewport/viewport_patch.go |   2 
internal/tui/bubbles/repo/bubble.go                 | 194 +-----
internal/tui/bubbles/selection/bubble.go            |   7 
internal/tui/commands.go                            |  63 +
internal/tui/git.go                                 | 105 +++
internal/tui/session.go                             |   6 
internal/tui/style/style.go                         |  95 +++
24 files changed, 1,718 insertions(+), 323 deletions(-)

Detailed changes

go.mod 🔗

@@ -3,8 +3,9 @@ module github.com/charmbracelet/soft-serve
 go 1.17
 
 require (
+	github.com/alecthomas/chroma v0.10.0
 	github.com/caarlos0/env/v6 v6.9.1
-	github.com/charmbracelet/bubbles v0.10.0
+	github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50
 	github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c
 	github.com/charmbracelet/glamour v0.4.0
 	github.com/charmbracelet/lipgloss v0.4.0
@@ -15,6 +16,8 @@ require (
 	github.com/go-git/go-git/v5 v5.4.2
 	github.com/matryer/is v1.2.0
 	github.com/muesli/reflow v0.3.0
+	github.com/muesli/termenv v0.11.0
+	github.com/sergi/go-diff v1.1.0 // indirect
 	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
 	gopkg.in/yaml.v2 v2.4.0
 )
@@ -23,8 +26,8 @@ require (
 	github.com/Microsoft/go-winio v0.5.1 // indirect
 	github.com/ProtonMail/go-crypto v0.0.0-20220113124808-70ae35bab23f // indirect
 	github.com/acomagu/bufpipe v1.0.3 // indirect
-	github.com/alecthomas/chroma v0.10.0 // indirect
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
+	github.com/atotto/clipboard v0.1.4 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/charmbracelet/keygen v0.1.2 // indirect
 	github.com/containerd/console v1.0.3 // indirect
@@ -42,10 +45,9 @@ require (
 	github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
-	github.com/muesli/termenv v0.11.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
-	github.com/sergi/go-diff v1.2.0 // indirect
+	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/xanzy/ssh-agent v0.3.1 // indirect
 	github.com/yuin/goldmark v1.4.4 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect

go.sum 🔗

@@ -15,13 +15,16 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
-github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
 github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
-github.com/charmbracelet/bubbles v0.10.0 h1:ZYqBwnmFGp91HSRRbhxKq5jr6bUPsVUBdkrGGWtv0Wk=
-github.com/charmbracelet/bubbles v0.10.0/go.mod h1:4tiDrWzH1MTD4t5NnrcthaedmI3MxU0FIutax7//dvk=
+github.com/charmbracelet/bubbles v0.10.3-0.20220206060452-06358c35f974 h1:mdlhYomJ2+TRS+py0WxxqjDdyjvX5ox+CyrCjV2HaUM=
+github.com/charmbracelet/bubbles v0.10.3-0.20220206060452-06358c35f974/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
+github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50 h1:hAsXGdqKHVoEbBlvReSfz8X605xddHMBFSxSrCaSSO4=
+github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
 github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
 github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c h1:hcS4xdVQwblKo8xuA5gRO/jql+yCVfnBlOwWcZrxOmA=
 github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c/go.mod h1:5nPeULOIxbAMykb3ggwhw1kruS7nP+Y4Za9yEH4J27U=
@@ -30,7 +33,6 @@ github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5Uy
 github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 github.com/charmbracelet/keygen v0.1.2 h1:Gr/gdIOjDIxCTRVXpwa9tsXPoJPS2eGNehPoMnZLvTQ=
 github.com/charmbracelet/keygen v0.1.2/go.mod h1:kFQ3Cvop12fXWX1K29vxDxV9x8ujG4wBSXq//GySSSk=
-github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk=
 github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
 github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
 github.com/charmbracelet/wish v0.2.1-0.20220208182816-534842b53d2a h1:dDdOcIedpXZ13xGfwFDd1ZlTUXotX945xXtz+7rHBK8=
@@ -81,12 +83,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
 github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
@@ -107,7 +109,6 @@ github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBc
 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
-github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
 github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
 github.com/muesli/termenv v0.11.0 h1:fwNUbu2mfWlgicwG7qYzs06aOI8Z/zKPAv8J4uKbT+o=
 github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
@@ -122,10 +123,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
-github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

internal/git/git.go 🔗

@@ -25,6 +25,7 @@ type Repo struct {
 	Repository  *git.Repository
 	Readme      string
 	LastUpdated *time.Time
+	commits     CommitLog
 }
 
 // RepoCommit contains metadata for a Git commit.
@@ -44,10 +45,9 @@ func (cl CommitLog) Less(i, j int) bool {
 
 // RepoSource is a reference to an on-disk repositories.
 type RepoSource struct {
-	Path    string
-	mtx     sync.Mutex
-	repos   []*Repo
-	commits CommitLog
+	Path  string
+	mtx   sync.Mutex
+	repos []*Repo
 }
 
 // NewRepoSource creates a new RepoSource.
@@ -106,14 +106,14 @@ func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
 	return r, nil
 }
 
-// GetCommits returns commits for the repository.
-func (rs *RepoSource) GetCommits(limit int) []RepoCommit {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	if limit > len(rs.commits) {
-		limit = len(rs.commits)
+func (r *Repo) GetCommits(limit int) CommitLog {
+	if limit <= 0 {
+		return r.commits
+	}
+	if limit > len(r.commits) {
+		limit = len(r.commits)
 	}
-	return rs.commits[:limit]
+	return r.commits[:limit]
 }
 
 // LoadRepos opens Git repositories.
@@ -125,7 +125,6 @@ func (rs *RepoSource) LoadRepos() error {
 		return err
 	}
 	rs.repos = make([]*Repo, 0)
-	rs.commits = make([]RepoCommit, 0)
 	for _, de := range rd {
 		rn := de.Name()
 		rg, err := git.PlainOpen(filepath.Join(rs.Path, rn))
@@ -143,6 +142,7 @@ func (rs *RepoSource) LoadRepos() error {
 
 func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) {
 	r := &Repo{Name: name}
+	r.commits = make([]RepoCommit, 0)
 	r.Repository = rg
 	l, err := rg.Log(&git.LogOptions{All: true})
 	if err != nil {
@@ -159,13 +159,13 @@ func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) {
 				}
 			}
 		}
-		rs.commits = append(rs.commits, RepoCommit{Name: name, Commit: c})
+		r.commits = append(r.commits, RepoCommit{Name: name, Commit: c})
 		return nil
 	})
 	if err != nil {
 		return nil, err
 	}
-	sort.Sort(rs.commits)
+	sort.Sort(r.commits)
 	return r, nil
 }
 

internal/tui/bubble.go 🔗

@@ -7,12 +7,17 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/internal/config"
+	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 	"github.com/gliderlabs/ssh"
 )
 
+const (
+	repoNameMaxWidth = 32
+)
+
 type sessionState int
 
 const (
@@ -83,14 +88,6 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return b, tea.Quit
 		case "tab", "shift+tab":
 			b.activeBox = (b.activeBox + 1) % 2
-		case "h", "left":
-			if b.activeBox > 0 {
-				b.activeBox--
-			}
-		case "l", "right":
-			if b.activeBox < len(b.boxes)-1 {
-				b.activeBox++
-			}
 		}
 	case errMsg:
 		b.error = msg.Error()
@@ -112,11 +109,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case selection.SelectedMsg:
 		b.activeBox = 1
 		rb := b.repoMenu[msg.Index].bubble
-		rb.GotoTop()
 		b.boxes[1] = rb
 	case selection.ActiveMsg:
-		rb := b.repoMenu[msg.Index].bubble
-		rb.GotoTop()
 		b.boxes[1] = b.repoMenu[msg.Index].bubble
 		cmds = append(cmds, func() tea.Msg {
 			return b.lastResize
@@ -163,27 +157,42 @@ func (b Bubble) headerView() string {
 
 func (b Bubble) footerView() string {
 	w := &strings.Builder{}
-	var h []helpEntry
-	switch b.state {
-	case errorState:
-		h = []helpEntry{{"q", "quit"}}
-	default:
-		h = []helpEntry{
+	var h []gittypes.HelpEntry
+	if b.state != errorState {
+		h = []gittypes.HelpEntry{
 			{"tab", "section"},
-			{"↑/↓", "navigate"},
-			{"q", "quit"},
 		}
-		if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
-			h = append(h[:2], helpEntry{"f/b", "pgdown/pgup"}, h[2])
+		if box, ok := b.boxes[b.activeBox].(gittypes.HelpableBubble); ok {
+			help := box.Help()
+			for _, he := range help {
+				h = append(h, he)
+			}
 		}
 	}
+	h = append(h, gittypes.HelpEntry{"q", "quit"})
 	for i, v := range h {
-		fmt.Fprint(w, v.Render(b.styles))
+		fmt.Fprint(w, helpEntryRender(v, b.styles))
 		if i != len(h)-1 {
 			fmt.Fprint(w, b.styles.HelpDivider)
 		}
 	}
-	return b.styles.Footer.Copy().Width(b.width).Render(w.String())
+	branch := ""
+	if b.state == loadedState {
+		branch = b.boxes[1].(*repo.Bubble).Reference().Short()
+	}
+	help := w.String()
+	branchMaxWidth := b.width - // bubble width
+		lipgloss.Width(help) - // help width
+		b.styles.App.GetHorizontalFrameSize() // App paddings
+	branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…"))
+	gap := lipgloss.NewStyle().
+		Width(b.width -
+			lipgloss.Width(help) -
+			lipgloss.Width(branch) -
+			b.styles.App.GetHorizontalFrameSize()).
+		Render("")
+	footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch)
+	return b.styles.Footer.Render(footer)
 }
 
 func (b Bubble) errorView() string {
@@ -219,11 +228,6 @@ func (b Bubble) View() string {
 	return b.styles.App.Render(s.String())
 }
 
-type helpEntry struct {
-	key string
-	val string
-}
-
-func (h helpEntry) Render(s *style.Styles) string {
-	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.key), s.HelpValue.Render(h.val))
+func helpEntryRender(h gittypes.HelpEntry, s *style.Styles) string {
+	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
 }

internal/tui/bubbles/commits/bubble.go 🔗

@@ -1,67 +0,0 @@
-package commits
-
-import (
-	"strings"
-
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/soft-serve/internal/git"
-	"github.com/dustin/go-humanize"
-)
-
-type Bubble struct {
-	Commits  []git.RepoCommit
-	Height   int
-	Width    int
-	viewport viewport.Model
-}
-
-func NewBubble(height int, width int, rcs []git.RepoCommit) *Bubble {
-	b := &Bubble{
-		Commits:  rcs,
-		viewport: viewport.Model{Height: height, 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.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.Width).Render(s)
-}
-
-func (b *Bubble) View() string {
-	return b.viewport.View()
-}

internal/tui/bubbles/commits/style.go 🔗

@@ -1,26 +0,0 @@
-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)

internal/tui/bubbles/git/about/bubble.go 🔗

@@ -0,0 +1,121 @@
+package about
+
+import (
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/glamour"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
+	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/muesli/reflow/wrap"
+)
+
+type Bubble struct {
+	readmeViewport *vp.ViewportBubble
+	repo           types.Repo
+	styles         *style.Styles
+	height         int
+	heightMargin   int
+	width          int
+	widthMargin    int
+}
+
+func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
+	b := &Bubble{
+		readmeViewport: &vp.ViewportBubble{
+			Viewport: &viewport.Model{},
+		},
+		repo:         repo,
+		styles:       styles,
+		widthMargin:  wm,
+		heightMargin: hm,
+	}
+	b.SetSize(width, height)
+	return b
+}
+func (b *Bubble) Init() tea.Cmd {
+	return b.setupCmd
+}
+
+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		b.SetSize(msg.Width, msg.Height)
+		// XXX: if we find that longer readmes take more than a few
+		// milliseconds to render we may need to move Glamour rendering into a
+		// command.
+		md, err := b.glamourize(b.repo.GetReadme())
+		if err != nil {
+			return b, nil
+		}
+		b.readmeViewport.Viewport.SetContent(md)
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "A":
+			b.GotoTop()
+		}
+	}
+	rv, cmd := b.readmeViewport.Update(msg)
+	b.readmeViewport = rv.(*vp.ViewportBubble)
+	cmds = append(cmds, cmd)
+	return b, tea.Batch(cmds...)
+}
+
+func (b *Bubble) SetSize(w, h int) {
+	b.width = w
+	b.height = h
+	b.readmeViewport.Viewport.Width = w - b.widthMargin
+	b.readmeViewport.Viewport.Height = h - b.heightMargin
+}
+
+func (b *Bubble) GotoTop() {
+	b.readmeViewport.Viewport.GotoTop()
+}
+
+func (b *Bubble) View() string {
+	return b.readmeViewport.View()
+}
+
+func (b *Bubble) Help() []types.HelpEntry {
+	return []types.HelpEntry{
+		{"f/b", "pgup/pgdown"},
+	}
+}
+
+func (b *Bubble) setupCmd() tea.Msg {
+	md, err := b.glamourize(b.repo.GetReadme())
+	if err != nil {
+		return types.ErrMsg{err}
+	}
+	b.readmeViewport.Viewport.SetContent(md)
+	b.GotoTop()
+	return nil
+}
+
+func (b *Bubble) glamourize(md string) (string, error) {
+	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
+	if w > types.GlamourMaxWidth {
+		w = types.GlamourMaxWidth
+	}
+	tr, err := glamour.NewTermRenderer(
+		glamour.WithStyles(types.DefaultStyles()),
+		glamour.WithWordWrap(w),
+	)
+
+	if err != nil {
+		return "", err
+	}
+	mdt, err := tr.Render(md)
+	if err != nil {
+		return "", err
+	}
+	// For now, hard-wrap long lines in Glamour that would otherwise break the
+	// layout when wrapping. This may be due to #43 in Reflow, which has to do
+	// with a bug in the way lines longer than the given width are wrapped.
+	//
+	//     https://github.com/muesli/reflow/issues/43
+	//
+	// TODO: solve this upstream in Glamour/Reflow.
+	return wrap.String(mdt, w), nil
+}

internal/tui/bubbles/git/bubble.go 🔗

@@ -0,0 +1,157 @@
+package git
+
+import (
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/about"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/log"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/tree"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/go-git/go-git/v5/plumbing"
+)
+
+const (
+	repoNameMaxWidth = 32
+)
+
+type pageState int
+
+const (
+	aboutPage pageState = iota
+	refsPage
+	logPage
+	treePage
+)
+
+type Bubble struct {
+	state        pageState
+	repo         types.Repo
+	height       int
+	heightMargin int
+	width        int
+	widthMargin  int
+	style        *style.Styles
+	boxes        []tea.Model
+}
+
+func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
+	b := &Bubble{
+		repo:         repo,
+		state:        aboutPage,
+		width:        width,
+		widthMargin:  wm,
+		height:       height,
+		heightMargin: hm,
+		style:        styles,
+		boxes:        make([]tea.Model, 4),
+	}
+	heightMargin := hm + lipgloss.Height(b.headerView())
+	b.boxes[aboutPage] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
+	b.boxes[refsPage] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
+	b.boxes[logPage] = log.NewBubble(repo, b.style, width, wm, height, heightMargin)
+	b.boxes[treePage] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin)
+	return b
+}
+
+func (b *Bubble) Init() tea.Cmd {
+	return b.setupCmd
+}
+
+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "A":
+			b.state = aboutPage
+		case "R":
+			b.state = refsPage
+		case "L":
+			b.state = logPage
+		case "T":
+			b.state = treePage
+		}
+	case tea.WindowSizeMsg:
+		b.width = msg.Width
+		b.height = msg.Height
+		for i, bx := range b.boxes {
+			m, cmd := bx.Update(msg)
+			b.boxes[i] = m
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	}
+	m, cmd := b.boxes[b.state].Update(msg)
+	b.boxes[b.state] = m
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "enter":
+			if b.state == refsPage {
+				b.state = treePage
+				cmds = append(cmds, b.boxes[b.state].Init())
+			}
+		}
+	}
+	return b, tea.Batch(cmds...)
+}
+
+func (b *Bubble) Help() []types.HelpEntry {
+	h := []types.HelpEntry{}
+	if b.state != treePage {
+		h = append(h, types.HelpEntry{"↑/↓", "navigate"})
+	}
+	h = append(h, b.boxes[b.state].(types.HelpableBubble).Help()...)
+	if b.state != aboutPage {
+		h = append(h, types.HelpEntry{"A", "about"})
+	}
+	if b.state != refsPage {
+		h = append(h, types.HelpEntry{"R", "refs"})
+	}
+	if b.state != logPage {
+		h = append(h, types.HelpEntry{"L", "log"})
+	}
+	if b.state != treePage {
+		h = append(h, types.HelpEntry{"T", "tree"})
+	}
+	return h
+}
+
+func (b *Bubble) Reference() plumbing.ReferenceName {
+	return b.repo.GetReference().Name()
+}
+
+func (b *Bubble) headerView() string {
+	// TODO better header, tabs?
+	return ""
+}
+
+func (b *Bubble) View() string {
+	header := b.headerView()
+	return header + b.boxes[b.state].View()
+}
+
+func (b *Bubble) setupCmd() tea.Msg {
+	cmds := make([]tea.Cmd, 0)
+	for _, bx := range b.boxes {
+		if bx != nil {
+			initCmd := bx.Init()
+			if initCmd != nil {
+				msg := initCmd()
+				switch msg := msg.(type) {
+				case types.ErrMsg:
+					return msg
+				}
+			}
+			cmds = append(cmds, initCmd)
+		}
+	}
+	return tea.Batch(cmds...)
+}

internal/tui/bubbles/git/log/bubble.go 🔗

@@ -0,0 +1,394 @@
+package log
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"math"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/list"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
+	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/dustin/go-humanize/english"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+var (
+	diffChroma = &gansi.CodeBlockElement{
+		Code:     "",
+		Language: "diff",
+	}
+)
+
+type commitMsg struct {
+	commit     *object.Commit
+	parent     *object.Commit
+	tree       *object.Tree
+	parentTree *object.Tree
+	patch      *object.Patch
+}
+
+type sessionState int
+
+const (
+	logState sessionState = iota
+	commitState
+	errorState
+)
+
+type item struct {
+	*types.Commit
+}
+
+func (i item) Title() string {
+	lines := strings.Split(i.Message, "\n")
+	if len(lines) > 0 {
+		return lines[0]
+	}
+	return ""
+}
+
+func (i item) FilterValue() string { return i.Title() }
+
+type itemDelegate struct {
+	style *style.Styles
+}
+
+func (d itemDelegate) Height() int                               { return 1 }
+func (d itemDelegate) Spacing() int                              { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	i, ok := listItem.(item)
+	if !ok {
+		return
+	}
+
+	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
+		d.style.LogItemSelector.GetWidth() +
+		d.style.LogItemHash.GetMarginLeft() +
+		d.style.LogItemHash.GetWidth() +
+		d.style.LogItemInactive.GetMarginLeft()
+	title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…")
+	if index == m.Index() {
+		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
+			d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+
+			d.style.LogItemActive.Render(title))
+	} else {
+		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
+			d.style.LogItemHash.Render(i.Hash.String()[:7])+
+			d.style.LogItemInactive.Render(title))
+	}
+}
+
+type Bubble struct {
+	repo           types.Repo
+	list           list.Model
+	state          sessionState
+	commitViewport *vp.ViewportBubble
+	style          *style.Styles
+	width          int
+	widthMargin    int
+	height         int
+	heightMargin   int
+	error          types.ErrMsg
+}
+
+func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+	l := list.New([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin)
+	l.SetShowFilter(false)
+	l.SetShowHelp(false)
+	l.SetShowPagination(false)
+	l.SetShowStatusBar(false)
+	l.SetShowTitle(false)
+	l.SetFilteringEnabled(false)
+	l.DisableQuitKeybindings()
+	l.KeyMap.NextPage = types.NextPage
+	l.KeyMap.PrevPage = types.PrevPage
+	b := &Bubble{
+		commitViewport: &vp.ViewportBubble{
+			Viewport: &viewport.Model{},
+		},
+		repo:         repo,
+		style:        style,
+		state:        logState,
+		width:        width,
+		widthMargin:  widthMargin,
+		height:       height,
+		heightMargin: heightMargin,
+		list:         l,
+	}
+	b.SetSize(width, height)
+	return b
+}
+
+func (b *Bubble) updateItems() tea.Cmd {
+	items := make([]list.Item, 0)
+	cc, err := b.repo.GetCommits(0)
+	if err != nil {
+		return func() tea.Msg { return types.ErrMsg{err} }
+	}
+	for _, c := range cc {
+		items = append(items, item{c})
+	}
+	return b.list.SetItems(items)
+}
+
+func (b *Bubble) Help() []types.HelpEntry {
+	switch b.state {
+	case logState:
+		return []types.HelpEntry{
+			{"enter", "select"},
+		}
+	default:
+		return []types.HelpEntry{
+			{"esc", "back"},
+		}
+	}
+}
+
+func (b *Bubble) GotoTop() {
+	b.commitViewport.Viewport.GotoTop()
+}
+
+func (b *Bubble) Init() tea.Cmd {
+	return b.updateItems()
+}
+
+func (b *Bubble) SetSize(width, height int) {
+	b.width = width
+	b.height = height
+	b.commitViewport.Viewport.Width = width - b.widthMargin
+	b.commitViewport.Viewport.Height = height - b.heightMargin
+	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
+}
+
+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		b.SetSize(msg.Width, msg.Height)
+
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "L":
+			b.state = logState
+			b.list.Select(0)
+			cmds = append(cmds, b.updateItems())
+		case "enter", "right", "l":
+			if b.state == logState {
+				cmds = append(cmds, b.loadCommit())
+			}
+		case "esc", "left", "h":
+			if b.state != logState {
+				b.state = logState
+			}
+		}
+	case types.ErrMsg:
+		b.error = msg
+		b.state = errorState
+		return b, nil
+	case commitMsg:
+		content := b.renderCommit(msg)
+		b.state = commitState
+		b.commitViewport.Viewport.SetContent(content)
+		b.GotoTop()
+	}
+
+	switch b.state {
+	case commitState:
+		rv, cmd := b.commitViewport.Update(msg)
+		b.commitViewport = rv.(*vp.ViewportBubble)
+		cmds = append(cmds, cmd)
+	case logState:
+		l, cmd := b.list.Update(msg)
+		b.list = l
+		cmds = append(cmds, cmd)
+	}
+
+	return b, tea.Batch(cmds...)
+}
+
+func (b *Bubble) loadCommit() tea.Cmd {
+	return func() tea.Msg {
+		i := b.list.SelectedItem()
+		if i == nil {
+			return nil
+		}
+		c, ok := i.(item)
+		if !ok {
+			return nil
+		}
+		// Using commit trees fixes the issue when generating diff for the first commit
+		// https://github.com/go-git/go-git/issues/281
+		tree, err := c.Tree()
+		if err != nil {
+			return types.ErrMsg{err}
+		}
+		var parent *object.Commit
+		parentTree := &object.Tree{}
+		if c.NumParents() > 0 {
+			parent, err = c.Parents().Next()
+			if err != nil {
+				return types.ErrMsg{err}
+			}
+			parentTree, err = parent.Tree()
+			if err != nil {
+				return types.ErrMsg{err}
+			}
+		}
+		ctx, cancel := context.WithTimeout(context.TODO(), types.MaxPatchWait)
+		defer cancel()
+		patch, err := parentTree.PatchContext(ctx, tree)
+		if err != nil {
+			return types.ErrMsg{err}
+		}
+		return commitMsg{
+			commit:     c.Commit.Commit,
+			tree:       tree,
+			parent:     parent,
+			parentTree: parentTree,
+			patch:      patch,
+		}
+	}
+}
+
+func (b *Bubble) renderCommit(m commitMsg) string {
+	s := strings.Builder{}
+	st := b.style
+	c := m.commit
+	// FIXME: lipgloss prints empty lines when CRLF is used
+	// sanitize commit message from CRLF
+	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
+	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
+		st.LogCommitHash.Render("commit "+c.Hash.String()),
+		st.LogCommitAuthor.Render("Author: "+c.Author.String()),
+		st.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
+		st.LogCommitBody.Render(msg),
+	))
+	stats := m.patch.Stats()
+	if len(stats) > types.MaxDiffFiles {
+		s.WriteString("\n" + types.ErrDiffFilesTooLong.Error())
+	} else {
+		s.WriteString("\n" + b.renderStats(stats))
+	}
+	ps := m.patch.String()
+	if len(strings.Split(ps, "\n")) > types.MaxDiffLines {
+		s.WriteString("\n" + types.ErrDiffTooLong.Error())
+	} else {
+		p := strings.Builder{}
+		diffChroma.Code = ps
+		err := diffChroma.Render(&p, types.RenderCtx)
+		if err != nil {
+			s.WriteString(fmt.Sprintf("\n%s", err.Error()))
+		} else {
+			s.WriteString(fmt.Sprintf("\n%s", p.String()))
+		}
+	}
+	return st.LogCommit.Copy().Width(b.width - b.widthMargin - st.LogCommit.GetHorizontalFrameSize()).Render(s.String())
+}
+
+func (b *Bubble) renderStats(fileStats object.FileStats) string {
+	padLength := float64(len(" "))
+	newlineLength := float64(len("\n"))
+	separatorLength := float64(len("|"))
+	// Soft line length limit. The text length calculation below excludes
+	// length of the change number. Adding that would take it closer to 80,
+	// but probably not more than 80, until it's a huge number.
+	lineLength := 72.0
+
+	// Get the longest filename and longest total change.
+	var longestLength float64
+	var longestTotalChange float64
+	for _, fs := range fileStats {
+		if int(longestLength) < len(fs.Name) {
+			longestLength = float64(len(fs.Name))
+		}
+		totalChange := fs.Addition + fs.Deletion
+		if int(longestTotalChange) < totalChange {
+			longestTotalChange = float64(totalChange)
+		}
+	}
+
+	// Parts of the output:
+	// <pad><filename><pad>|<pad><changeNumber><pad><+++/---><newline>
+	// example: " main.go | 10 +++++++--- "
+
+	// <pad><filename><pad>
+	leftTextLength := padLength + longestLength + padLength
+
+	// <pad><number><pad><+++++/-----><newline>
+	// Excluding number length here.
+	rightTextLength := padLength + padLength + newlineLength
+
+	totalTextArea := leftTextLength + separatorLength + rightTextLength
+	heightOfHistogram := lineLength - totalTextArea
+
+	// Scale the histogram.
+	var scaleFactor float64
+	if longestTotalChange > heightOfHistogram {
+		// Scale down to heightOfHistogram.
+		scaleFactor = longestTotalChange / heightOfHistogram
+	} else {
+		scaleFactor = 1.0
+	}
+
+	taddc := 0
+	tdelc := 0
+	output := strings.Builder{}
+	for _, fs := range fileStats {
+		taddc += fs.Addition
+		tdelc += fs.Deletion
+		addn := float64(fs.Addition)
+		deln := float64(fs.Deletion)
+		addc := int(math.Floor(addn / scaleFactor))
+		delc := int(math.Floor(deln / scaleFactor))
+		if addc < 0 {
+			addc = 0
+		}
+		if delc < 0 {
+			delc = 0
+		}
+		adds := strings.Repeat("+", addc)
+		dels := strings.Repeat("-", delc)
+		diffLines := fmt.Sprint(fs.Addition + fs.Deletion)
+		totalDiffLines := fmt.Sprint(int(longestTotalChange))
+		fmt.Fprintf(&output, "%s | %s %s%s\n",
+			fs.Name+strings.Repeat(" ", int(longestLength)-len(fs.Name)),
+			strings.Repeat(" ", len(totalDiffLines)-len(diffLines))+diffLines,
+			b.style.LogCommitStatsAdd.Render(adds),
+			b.style.LogCommitStatsDel.Render(dels))
+	}
+	files := len(fileStats)
+	fc := fmt.Sprintf("%s changed", english.Plural(files, "file", ""))
+	ins := fmt.Sprintf("%s(+)", english.Plural(taddc, "insertion", ""))
+	dels := fmt.Sprintf("%s(-)", english.Plural(tdelc, "deletion", ""))
+	fmt.Fprint(&output, fc)
+	if taddc > 0 {
+		fmt.Fprintf(&output, ", %s", ins)
+	}
+	if tdelc > 0 {
+		fmt.Fprintf(&output, ", %s", dels)
+	}
+	fmt.Fprint(&output, "\n")
+
+	return output.String()
+}
+
+func (b *Bubble) View() string {
+	switch b.state {
+	case logState:
+		return b.list.View()
+	case errorState:
+		return b.error.ViewWithPrefix(b.style, "Error")
+	case commitState:
+		return b.commitViewport.View()
+	default:
+		return ""
+	}
+}

internal/tui/bubbles/git/refs/bubble.go 🔗

@@ -0,0 +1,174 @@
+package refs
+
+import (
+	"fmt"
+	"io"
+	"sort"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/go-git/go-git/v5/plumbing"
+)
+
+type item struct {
+	*plumbing.Reference
+}
+
+func (i item) Short() string {
+	return i.Name().Short()
+}
+
+func (i item) FilterValue() string { return i.Short() }
+
+type items []item
+
+func (cl items) Len() int      { return len(cl) }
+func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+func (cl items) Less(i, j int) bool {
+	return cl[i].Name().Short() < cl[j].Name().Short()
+}
+
+type itemDelegate struct {
+	style *style.Styles
+}
+
+func (d itemDelegate) Height() int                               { return 1 }
+func (d itemDelegate) Spacing() int                              { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	s := d.style
+	i, ok := listItem.(item)
+	if !ok {
+		return
+	}
+
+	ref := i.Short()
+	if i.Name().IsTag() {
+		ref = s.RefItemTag.Render(ref)
+	}
+	ref = s.RefItemBranch.Render(ref)
+	refMaxWidth := m.Width() -
+		s.RefItemSelector.GetMarginLeft() -
+		s.RefItemSelector.GetWidth() -
+		s.RefItemInactive.GetMarginLeft()
+	ref = types.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))
+	}
+}
+
+type Bubble struct {
+	repo         types.Repo
+	list         list.Model
+	style        *style.Styles
+	width        int
+	widthMargin  int
+	height       int
+	heightMargin int
+}
+
+func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+	l := list.NewModel([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin)
+	l.SetShowFilter(false)
+	l.SetShowHelp(false)
+	l.SetShowPagination(false)
+	l.SetShowStatusBar(false)
+	l.SetShowTitle(false)
+	l.SetFilteringEnabled(false)
+	l.DisableQuitKeybindings()
+	b := &Bubble{
+		repo:         repo,
+		style:        style,
+		width:        width,
+		height:       height,
+		widthMargin:  widthMargin,
+		heightMargin: heightMargin,
+		list:         l,
+	}
+	b.SetSize(width, height)
+	return b
+}
+
+func (b *Bubble) SetBranch(ref *plumbing.Reference) {
+	b.repo.SetReference(ref)
+}
+
+func (b *Bubble) Init() tea.Cmd {
+	return b.updateItems()
+}
+
+func (b *Bubble) SetSize(width, height int) {
+	b.width = width
+	b.height = height
+	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
+}
+
+func (b *Bubble) Help() []types.HelpEntry {
+	return []types.HelpEntry{
+		{"enter", "select"},
+	}
+}
+
+func (b *Bubble) updateItems() tea.Cmd {
+	its := make(items, 0)
+	tags := make(items, 0)
+	ri, err := b.repo.Repository().References()
+	if err != nil {
+		return nil
+	}
+	if err = ri.ForEach(func(r *plumbing.Reference) error {
+		if r.Type() == plumbing.HashReference {
+			if r.Name().IsTag() {
+				tags = append(tags, item{r})
+			} else {
+				its = append(its, item{r})
+			}
+		}
+		return nil
+	}); err != nil {
+		return nil
+	}
+	sort.Sort(its)
+	sort.Sort(tags)
+	its = append(its, tags...)
+	itt := make([]list.Item, len(its))
+	for i, it := range its {
+		itt[i] = it
+	}
+	return b.list.SetItems(itt)
+}
+
+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		b.SetSize(msg.Width, msg.Height)
+
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "R":
+			cmds = append(cmds, b.updateItems())
+		case "enter", "right", "l":
+			if b.list.Index() >= 0 {
+				ref := b.list.SelectedItem().(item).Reference
+				b.SetBranch(ref)
+			}
+		}
+	}
+
+	l, cmd := b.list.Update(msg)
+	b.list = l
+	cmds = append(cmds, cmd)
+
+	return b, tea.Batch(cmds...)
+}
+
+func (b *Bubble) View() string {
+	return b.list.View()
+}

internal/tui/bubbles/git/tree/bubble.go 🔗

@@ -0,0 +1,355 @@
+package tree
+
+import (
+	"fmt"
+	"io"
+	"path/filepath"
+	"sort"
+	"strings"
+
+	"github.com/alecthomas/chroma/lexers"
+	"github.com/charmbracelet/bubbles/list"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
+	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/dustin/go-humanize"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/filemode"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type fileMsg struct {
+	content string
+}
+
+type sessionState int
+
+const (
+	treeState sessionState = iota
+	fileState
+	errorState
+)
+
+type item struct {
+	*object.TreeEntry
+	*object.File
+}
+
+func (i item) Name() string {
+	return i.TreeEntry.Name
+}
+
+func (i item) Mode() filemode.FileMode {
+	return i.TreeEntry.Mode
+}
+
+func (i item) FilterValue() string { return i.Name() }
+
+type items []item
+
+func (cl items) Len() int      { return len(cl) }
+func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+func (cl items) Less(i, j int) bool {
+	if cl[i].Mode() == filemode.Dir && cl[j].Mode() == filemode.Dir {
+		return cl[i].Name() < cl[j].Name()
+	} else if cl[i].Mode() == filemode.Dir {
+		return true
+	} else if cl[j].Mode() == filemode.Dir {
+		return false
+	} else {
+		return cl[i].Name() < cl[j].Name()
+	}
+}
+
+type itemDelegate struct {
+	style *style.Styles
+}
+
+func (d itemDelegate) Height() int                               { return 1 }
+func (d itemDelegate) Spacing() int                              { return 0 }
+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	s := d.style
+	i, ok := listItem.(item)
+	if !ok {
+		return
+	}
+
+	name := i.Name()
+	if i.Mode() == filemode.Dir {
+		name = s.TreeFileDir.Render(name)
+	}
+	size := ""
+	if i.File != nil {
+		size = humanize.Bytes(uint64(i.File.Size))
+	}
+	var cs lipgloss.Style
+	mode, _ := i.Mode().ToOSFileMode()
+	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 = types.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))
+}
+
+type Bubble struct {
+	repo         types.Repo
+	list         list.Model
+	style        *style.Styles
+	width        int
+	widthMargin  int
+	height       int
+	heightMargin int
+	path         string
+	state        sessionState
+	error        types.ErrMsg
+	fileViewport *vp.ViewportBubble
+	lastSelected []int
+}
+
+func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
+	l := list.New([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin)
+	l.SetShowFilter(false)
+	l.SetShowHelp(false)
+	l.SetShowPagination(false)
+	l.SetShowStatusBar(false)
+	l.SetShowTitle(false)
+	l.SetFilteringEnabled(false)
+	l.DisableQuitKeybindings()
+	l.KeyMap.NextPage = types.NextPage
+	l.KeyMap.PrevPage = types.PrevPage
+	b := &Bubble{
+		fileViewport: &vp.ViewportBubble{
+			Viewport: &viewport.Model{},
+		},
+		repo:         repo,
+		style:        style,
+		width:        width,
+		height:       height,
+		widthMargin:  widthMargin,
+		heightMargin: heightMargin,
+		list:         l,
+		path:         "",
+		state:        treeState,
+		lastSelected: []int{},
+	}
+	b.SetSize(width, height)
+	return b
+}
+
+func (b *Bubble) Init() tea.Cmd {
+	b.path = ""
+	b.list.Select(0)
+	b.state = treeState
+	return b.updateItems()
+}
+
+func (b *Bubble) SetSize(width, height int) {
+	b.width = width
+	b.height = height
+	b.fileViewport.Viewport.Width = width - b.widthMargin
+	b.fileViewport.Viewport.Height = height - b.heightMargin
+	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
+}
+
+func (b *Bubble) Help() []types.HelpEntry {
+	h := []types.HelpEntry{}
+	switch b.state {
+	case errorState:
+		h = append(h, types.HelpEntry{"esc", "back"})
+	default:
+		h = append(h, types.HelpEntry{"←/↑/→/↓", "navigate"})
+	}
+	return h
+}
+
+func (b *Bubble) updateItems() tea.Cmd {
+	its := make(items, 0)
+	t, err := b.repo.Tree(b.path)
+	if err != nil {
+		return func() tea.Msg { return types.ErrMsg{err} }
+	}
+	tw := object.NewTreeWalker(t, false, map[plumbing.Hash]bool{})
+	defer tw.Close()
+	for {
+		_, e, err := tw.Next()
+		if err != nil {
+			break
+		}
+		i := item{
+			TreeEntry: &e,
+		}
+		if e.Mode.IsFile() {
+			if f, err := t.TreeEntryFile(&e); err == nil {
+				i.File = f
+			}
+		}
+		its = append(its, i)
+	}
+	sort.Sort(its)
+	itt := make([]list.Item, len(its))
+	for i, it := range its {
+		itt[i] = it
+	}
+	cmd := b.list.SetItems(itt)
+	b.list.Select(0)
+	return cmd
+}
+
+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		b.SetSize(msg.Width, msg.Height)
+
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "T":
+			b.state = treeState
+			b.path = ""
+			cmds = append(cmds, b.updateItems())
+		case "enter", "right", "l":
+			if b.state == treeState {
+				index := b.list.Index()
+				item := b.list.SelectedItem().(item)
+				mode := item.Mode()
+				b.path = filepath.Join(b.path, item.Name())
+				if mode == filemode.Dir {
+					b.lastSelected = append(b.lastSelected, index)
+					cmds = append(cmds, b.updateItems())
+				} else {
+					b.lastSelected = append(b.lastSelected, index)
+					cmds = append(cmds, b.loadFile())
+				}
+			}
+		case "esc", "left", "h":
+			if b.state != treeState {
+				b.state = treeState
+			}
+			p := filepath.Dir(b.path)
+			b.path = p
+			cmds = append(cmds, b.updateItems())
+			index := 0
+			if len(b.lastSelected) > 0 {
+				index = b.lastSelected[len(b.lastSelected)-1]
+				b.lastSelected = b.lastSelected[:len(b.lastSelected)-1]
+			}
+			b.list.Select(index)
+		}
+
+	case types.ErrMsg:
+		b.error = msg
+		b.state = errorState
+		return b, nil
+
+	case fileMsg:
+		content := b.renderFile(msg)
+		b.fileViewport.Viewport.SetContent(content)
+		b.fileViewport.Viewport.GotoTop()
+		b.state = fileState
+	}
+
+	switch b.state {
+	case fileState:
+		rv, cmd := b.fileViewport.Update(msg)
+		b.fileViewport = rv.(*vp.ViewportBubble)
+		cmds = append(cmds, cmd)
+	case treeState:
+		l, cmd := b.list.Update(msg)
+		b.list = l
+		cmds = append(cmds, cmd)
+	}
+
+	return b, tea.Batch(cmds...)
+}
+
+func (b *Bubble) View() string {
+	switch b.state {
+	case treeState:
+		return b.list.View()
+	case errorState:
+		return b.error.ViewWithPrefix(b.style, "Error")
+	case fileState:
+		return b.fileViewport.View()
+	default:
+		return ""
+	}
+}
+
+func (b *Bubble) loadFile() tea.Cmd {
+	return func() tea.Msg {
+		i := b.list.SelectedItem()
+		if i == nil {
+			return nil
+		}
+		f, ok := i.(item)
+		if !ok {
+			return nil
+		}
+		if !f.Mode().IsFile() || f.File == nil {
+			return types.ErrMsg{types.ErrInvalidFile}
+		}
+		bin, err := f.File.IsBinary()
+		if err != nil {
+			return types.ErrMsg{err}
+		}
+		if bin {
+			return types.ErrMsg{types.ErrBinaryFile}
+		}
+		c, err := f.File.Contents()
+		if err != nil {
+			return types.ErrMsg{err}
+		}
+		return fileMsg{
+			content: c,
+		}
+	}
+}
+
+func (b *Bubble) renderFile(m fileMsg) string {
+	s := strings.Builder{}
+	c := m.content
+	if len(strings.Split(c, "\n")) > types.MaxDiffLines {
+		s.WriteString(types.ErrFileTooLarge.Error())
+	} else {
+		lexer := lexers.Match(b.path)
+		lang := ""
+		if lexer != nil && lexer.Config() != nil {
+			lang = lexer.Config().Name
+		}
+		formatter := &gansi.CodeBlockElement{
+			Code:     c,
+			Language: lang,
+		}
+		r := strings.Builder{}
+		err := formatter.Render(&r, types.RenderCtx)
+		if err != nil {
+			s.WriteString(err.Error())
+		} else {
+			s.WriteString(r.String())
+		}
+	}
+	return b.style.TreeFileContent.Copy().Width(b.width - b.widthMargin).Render(s.String())
+}

internal/tui/bubbles/git/types/consts.go 🔗

@@ -0,0 +1,28 @@
+package types
+
+import (
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+)
+
+// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git
+
+const (
+	GlamourMaxWidth  = 120
+	RepoNameMaxWidth = 32
+	MaxDiffLines     = 1000
+	MaxDiffFiles     = 100
+	MaxPatchWait     = time.Second * 3
+)
+
+var (
+	PrevPage = key.NewBinding(
+		key.WithKeys("pgup", "b", "u"),
+		key.WithHelp("pgup", "prev page"),
+	)
+	NextPage = key.NewBinding(
+		key.WithKeys("pgdown", "f", "d"),
+		key.WithHelp("pgdn", "next page"),
+	)
+)

internal/tui/bubbles/git/types/error.go 🔗

@@ -0,0 +1,36 @@
+package types
+
+import (
+	"errors"
+
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/internal/tui/style"
+)
+
+var (
+	ErrDiffTooLong      = errors.New("diff is too long")
+	ErrDiffFilesTooLong = errors.New("diff files are too long")
+	ErrBinaryFile       = errors.New("binary file")
+	ErrFileTooLarge     = errors.New("file is too large")
+	ErrInvalidFile      = errors.New("invalid file")
+)
+
+type ErrMsg struct {
+	Err error
+}
+
+func (e ErrMsg) Error() string {
+	return e.Err.Error()
+}
+
+func (e ErrMsg) View(s *style.Styles) string {
+	return e.ViewWithPrefix(s, "")
+}
+
+func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string {
+	return lipgloss.JoinHorizontal(
+		lipgloss.Top,
+		s.ErrorTitle.Render(prefix),
+		s.ErrorBody.Render(e.Error()),
+	)
+}

internal/tui/bubbles/git/types/formatter.go 🔗

@@ -0,0 +1,36 @@
+package types
+
+import (
+	"github.com/charmbracelet/glamour"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/muesli/termenv"
+)
+
+var (
+	RenderCtx = DefaultRenderCtx()
+	Styles    = DefaultStyles()
+)
+
+func DefaultStyles() gansi.StyleConfig {
+	noColor := ""
+	s := glamour.DarkStyleConfig
+	s.Document.StylePrimitive.Color = &noColor
+	s.CodeBlock.Chroma.Text.Color = &noColor
+	s.CodeBlock.Chroma.Name.Color = &noColor
+	return s
+}
+
+func DefaultRenderCtx() gansi.RenderContext {
+	return gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       DefaultStyles(),
+	})
+}
+
+func NewRenderCtx(worldwrap int) gansi.RenderContext {
+	return gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       DefaultStyles(),
+		WordWrap:     worldwrap,
+	})
+}

internal/tui/bubbles/git/types/git.go 🔗

@@ -0,0 +1,29 @@
+package types
+
+import (
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type Repo interface {
+	Name() string
+	GetReference() *plumbing.Reference
+	SetReference(*plumbing.Reference) error
+	GetReadme() string
+	GetCommits(limit int) (Commits, error)
+	Repository() *git.Repository
+	Tree(path string) (*object.Tree, error)
+}
+
+type Commit struct {
+	*object.Commit
+}
+
+type Commits []*Commit
+
+func (cl Commits) Len() int      { return len(cl) }
+func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+func (cl Commits) Less(i, j int) bool {
+	return cl[i].Author.When.After(cl[j].Author.When)
+}

internal/tui/bubbles/git/types/utils.go 🔗

@@ -0,0 +1,17 @@
+package types
+
+import "github.com/muesli/reflow/truncate"
+
+func TruncateString(s string, max int, tail string) string {
+	if max < 0 {
+		max = 0
+	}
+	return truncate.StringWithTail(s, uint(max), tail)
+}
+
+func Max(a, b int) int {
+	if a > b {
+		return a
+	}
+	return b
+}

internal/tui/bubbles/repo/bubble.go 🔗

@@ -1,111 +1,71 @@
 package repo
 
 import (
-	"bytes"
 	"fmt"
 	"strconv"
-	"text/template"
 
-	"github.com/charmbracelet/bubbles/viewport"
 	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/glamour"
-	"github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/git"
+	gitui "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git"
+	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
+	"github.com/go-git/go-git/v5/plumbing"
 	"github.com/muesli/reflow/truncate"
 	"github.com/muesli/reflow/wrap"
 )
 
 const (
-	glamourMaxWidth  = 120
 	repoNameMaxWidth = 32
 )
 
-var glamourStyle = func() ansi.StyleConfig {
-	noColor := ""
-	s := glamour.DarkStyleConfig
-	s.Document.StylePrimitive.Color = &noColor
-	s.CodeBlock.Chroma.Text.Color = &noColor
-	s.CodeBlock.Chroma.Name.Color = &noColor
-	return s
-}()
-
-type ErrMsg struct {
-	Error error
-}
-
 type Bubble struct {
-	templateObject interface{}
-	repoSource     *git.RepoSource
-	name           string
-	repo           *git.Repo
-	styles         *style.Styles
-	readmeViewport *ViewportBubble
-	readme         string
-	height         int
-	heightMargin   int
-	width          int
-	widthMargin    int
-	Active         bool
-
-	// XXX: ideally, we get these from the parent as a pointer. Currently, we
-	// can't add a *tui.Config because it's an illegal import cycle. One
-	// solution would be to (rename and) move this Bubble into the parent
-	// package.
-	Host string
-	Port int
+	name         string
+	host         string
+	port         int
+	repo         gittypes.Repo
+	styles       *style.Styles
+	width        int
+	widthMargin  int
+	height       int
+	heightMargin int
+	box          *gitui.Bubble
+
+	Active bool
 }
 
-func NewBubble(rs *git.RepoSource, name string, styles *style.Styles, width, wm, height, hm int, tmp interface{}) *Bubble {
+func NewBubble(name, host string, port int, repo gittypes.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
 	b := &Bubble{
-		templateObject: tmp,
-		repoSource:     rs,
-		name:           name,
-		styles:         styles,
-		heightMargin:   hm,
-		widthMargin:    wm,
-		readmeViewport: &ViewportBubble{
-			Viewport: &viewport.Model{},
-		},
-	}
-	b.SetSize(width, height)
+		name:         name,
+		host:         host,
+		port:         port,
+		repo:         repo,
+		width:        width,
+		widthMargin:  wm,
+		height:       height,
+		heightMargin: hm,
+		styles:       styles,
+	}
+	b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize())
 	return b
 }
 
 func (b *Bubble) Init() tea.Cmd {
-	return b.setupCmd
+	return b.box.Init()
 }
 
 func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-		// XXX: if we find that longer readmes take more than a few
-		// milliseconds to render we may need to move Glamour rendering into a
-		// command.
-		md, err := b.glamourize(b.readme)
-		if err != nil {
-			return b, nil
-		}
-		b.readmeViewport.Viewport.SetContent(md)
+		b.width = msg.Width
+		b.height = msg.Height
 	}
-	rv, cmd := b.readmeViewport.Update(msg)
-	b.readmeViewport = rv.(*ViewportBubble)
-	cmds = append(cmds, cmd)
-	return b, tea.Batch(cmds...)
+	box, cmd := b.box.Update(msg)
+	b.box = box.(*gitui.Bubble)
+	return b, cmd
 }
 
-func (b *Bubble) SetSize(w, h int) {
-	b.width = w
-	b.height = h
-	b.readmeViewport.Viewport.Width = w - b.widthMargin
-	b.readmeViewport.Viewport.Height = h - lipgloss.Height(b.headerView()) - b.heightMargin
-}
-
-func (b *Bubble) GotoTop() {
-	b.readmeViewport.Viewport.GotoTop()
+func (b *Bubble) Help() []gittypes.HelpEntry {
+	return b.box.Help()
 }
 
 func (b Bubble) headerView() string {
@@ -135,7 +95,7 @@ func (b Bubble) headerView() string {
 	note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
 
 	// Render borders on name and command
-	height := max(lipgloss.Height(title), lipgloss.Height(note))
+	height := gittypes.Max(lipgloss.Height(title), lipgloss.Height(note))
 	titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
 	noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
 	if b.Active {
@@ -157,86 +117,18 @@ func (b *Bubble) View() string {
 	}
 	body := bs.Width(b.width - b.widthMargin - b.styles.RepoBody.GetVerticalFrameSize()).
 		Height(b.height - b.heightMargin - lipgloss.Height(header)).
-		Render(b.readmeViewport.View())
+		Render(b.box.View())
 	return header + body
 }
 
+func (b *Bubble) Reference() plumbing.ReferenceName {
+	return b.box.Reference()
+}
+
 func (b Bubble) sshAddress() string {
-	p := ":" + strconv.Itoa(int(b.Port))
+	p := ":" + strconv.Itoa(int(b.port))
 	if p == ":22" {
 		p = ""
 	}
-	return fmt.Sprintf("ssh://%s%s/%s", b.Host, p, b.name)
-}
-
-func (b *Bubble) setupCmd() tea.Msg {
-	r, err := b.repoSource.GetRepo(b.name)
-	if err == git.ErrMissingRepo {
-		return nil
-	}
-	if err != nil {
-		return ErrMsg{err}
-	}
-	md := r.Readme
-	if b.templateObject != nil {
-		md, err = b.templatize(md)
-		if err != nil {
-			return ErrMsg{err}
-		}
-	}
-	b.readme = md
-	md, err = b.glamourize(md)
-	if err != nil {
-		return ErrMsg{err}
-	}
-	b.readmeViewport.Viewport.SetContent(md)
-	b.GotoTop()
-	return nil
-}
-
-func (b *Bubble) templatize(mdt string) (string, error) {
-	t, err := template.New("readme").Parse(mdt)
-	if err != nil {
-		return "", err
-	}
-	buf := &bytes.Buffer{}
-	err = t.Execute(buf, b.templateObject)
-	if err != nil {
-		return "", err
-	}
-	return buf.String(), nil
-}
-
-func (b *Bubble) glamourize(md string) (string, error) {
-	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
-	if w > glamourMaxWidth {
-		w = glamourMaxWidth
-	}
-	tr, err := glamour.NewTermRenderer(
-		glamour.WithStyles(glamourStyle),
-		glamour.WithWordWrap(w),
-	)
-
-	if err != nil {
-		return "", err
-	}
-	mdt, err := tr.Render(md)
-	if err != nil {
-		return "", err
-	}
-	// For now, hard-wrap long lines in Glamour that would otherwise break the
-	// layout when wrapping. This may be due to #43 in Reflow, which has to do
-	// with a bug in the way lines longer than the given width are wrapped.
-	//
-	//     https://github.com/muesli/reflow/issues/43
-	//
-	// TODO: solve this upstream in Glamour/Reflow.
-	return wrap.String(mdt, w), nil
-}
-
-func max(a, b int) int {
-	if a > b {
-		return a
-	}
-	return b
+	return fmt.Sprintf("ssh://%s%s/%s", b.host, p, b.name)
 }

internal/tui/bubbles/selection/bubble.go 🔗

@@ -5,6 +5,7 @@ import (
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 	"github.com/muesli/reflow/truncate"
 )
@@ -79,6 +80,12 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	return b, tea.Batch(cmds...)
 }
 
+func (b *Bubble) Help() []gittypes.HelpEntry {
+	return []gittypes.HelpEntry{
+		{"↑/↓", "navigate"},
+	}
+}
+
 func (b *Bubble) sendActiveMessage() tea.Msg {
 	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
 		return ActiveMsg{

internal/tui/commands.go 🔗

@@ -1,12 +1,14 @@
 package tui
 
 import (
+	"bytes"
 	"fmt"
+	"text/template"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/config"
-	br "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
+	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 	gm "github.com/charmbracelet/wish/git"
 )
@@ -93,37 +95,54 @@ func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) {
 	return mes, nil
 }
 
-func (b *Bubble) newMenuEntry(name string, repo string) (MenuEntry, error) {
-	var tmplConfig *config.Config
-	if repo == "config" {
-		tmplConfig = b.config
+func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
+	gr := &Repo{
+		name: rn,
+	}
+	me := MenuEntry{Name: name, Repo: rn}
+	r, err := b.config.Source.GetRepo(rn)
+	if err != nil {
+		return me, err
+	}
+	if rn == "config" {
+		md, err := templatize(r.Readme, b.config)
+		if err != nil {
+			return me, err
+		}
+		r.Readme = md
+	}
+	gr.repo = r.Repository
+	gr.readme = r.Readme
+	gr.ref, err = r.Repository.Head()
+	if err != nil {
+		return me, err
 	}
-	me := MenuEntry{Name: name, Repo: repo}
-	width := b.width
 	boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize()
 	// TODO: also send this along with a tea.WindowSizeMsg
 	var heightMargin = lipgloss.Height(b.headerView()) +
 		lipgloss.Height(b.footerView()) +
 		b.styles.RepoBody.GetVerticalFrameSize() +
 		b.styles.App.GetVerticalMargins()
-	rb := br.NewBubble(
-		b.config.Source,
-		me.Repo,
-		b.styles,
-		width,
-		boxLeftWidth,
-		b.height,
-		heightMargin,
-		tmplConfig,
-	)
-	rb.Host = b.config.Host
-	rb.Port = b.config.Port
+	rb := repo.NewBubble(rn, b.config.Host, b.config.Port, gr, b.styles, b.width, boxLeftWidth, b.height, heightMargin)
 	initCmd := rb.Init()
 	msg := initCmd()
 	switch msg := msg.(type) {
-	case br.ErrMsg:
-		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Error)
+	case gitypes.ErrMsg:
+		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error())
 	}
 	me.bubble = rb
 	return me, nil
 }
+
+func templatize(mdt string, tmpl interface{}) (string, error) {
+	t, err := template.New("readme").Parse(mdt)
+	if err != nil {
+		return "", err
+	}
+	buf := &bytes.Buffer{}
+	err = t.Execute(buf, tmpl)
+	if err != nil {
+		return "", err
+	}
+	return buf.String(), nil
+}

internal/tui/git.go 🔗

@@ -0,0 +1,105 @@
+package tui
+
+import (
+	"path/filepath"
+
+	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
+	"github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing"
+	"github.com/go-git/go-git/v5/plumbing/object"
+)
+
+type Repo struct {
+	name   string
+	repo   *git.Repository
+	readme string
+	ref    *plumbing.Reference
+}
+
+func (r *Repo) Name() string {
+	return r.name
+}
+
+func (r *Repo) GetReference() *plumbing.Reference {
+	return r.ref
+}
+
+func (r *Repo) SetReference(ref *plumbing.Reference) error {
+	r.ref = ref
+	return nil
+}
+
+func (r *Repo) Repository() *git.Repository {
+	return r.repo
+}
+
+func (r *Repo) Tree(path string) (*object.Tree, error) {
+	path = filepath.Clean(path)
+	c, err := r.repo.CommitObject(r.ref.Hash())
+	if err != nil {
+		return nil, err
+	}
+	t, err := c.Tree()
+	if err != nil {
+		return nil, err
+	}
+	if path == "." {
+		return t, nil
+	}
+	return t.Tree(path)
+}
+
+func (r *Repo) GetCommits(limit int) (gitypes.Commits, error) {
+	commits := gitypes.Commits{}
+	l, err := r.repo.Log(&git.LogOptions{
+		Order: git.LogOrderCommitterTime,
+		From:  r.ref.Hash(),
+	})
+	if err != nil {
+		return nil, err
+	}
+	err = l.ForEach(func(c *object.Commit) error {
+		commits = append(commits, &gitypes.Commit{c})
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	if limit <= 0 || limit > len(commits) {
+		limit = len(commits)
+	}
+	return commits[:limit], nil
+}
+
+func (r *Repo) GetReadme() string {
+	if r.readme != "" {
+		return r.readme
+	}
+	md, err := r.readFile("README.md")
+	if err != nil {
+		return ""
+	}
+	return md
+}
+
+func (r *Repo) readFile(path string) (string, error) {
+	lg, err := r.repo.Log(&git.LogOptions{
+		From: r.ref.Hash(),
+	})
+	if err != nil {
+		return "", err
+	}
+	c, err := lg.Next()
+	if err != nil {
+		return "", err
+	}
+	f, err := c.File(path)
+	if err != nil {
+		return "", err
+	}
+	content, err := f.Contents()
+	if err != nil {
+		return "", err
+	}
+	return content, nil
+}

internal/tui/session.go 🔗

@@ -2,6 +2,8 @@ package tui
 
 import (
 	"fmt"
+	"net/url"
+	"strings"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/internal/config"
@@ -16,6 +18,10 @@ func SessionHandler(cfg *config.Config) func(ssh.Session) (tea.Model, []tea.Prog
 		case 0:
 			scfg.InitialRepo = ""
 		case 1:
+			p, err := url.Parse(cmd[0])
+			if err != nil || strings.Contains(p.Path, "/") {
+				return nil, nil
+			}
 			scfg.InitialRepo = cmd[0]
 		default:
 			return nil, nil

internal/tui/style/style.go 🔗

@@ -31,6 +31,7 @@ type Styles struct {
 	RepoBody     lipgloss.Style
 
 	Footer      lipgloss.Style
+	Branch      lipgloss.Style
 	HelpKey     lipgloss.Style
 	HelpValue   lipgloss.Style
 	HelpDivider lipgloss.Style
@@ -38,6 +39,32 @@ type Styles struct {
 	Error      lipgloss.Style
 	ErrorTitle lipgloss.Style
 	ErrorBody  lipgloss.Style
+
+	LogItemSelector   lipgloss.Style
+	LogItemActive     lipgloss.Style
+	LogItemInactive   lipgloss.Style
+	LogItemHash       lipgloss.Style
+	LogCommit         lipgloss.Style
+	LogCommitHash     lipgloss.Style
+	LogCommitAuthor   lipgloss.Style
+	LogCommitDate     lipgloss.Style
+	LogCommitBody     lipgloss.Style
+	LogCommitStatsAdd lipgloss.Style
+	LogCommitStatsDel lipgloss.Style
+
+	RefItemSelector lipgloss.Style
+	RefItemActive   lipgloss.Style
+	RefItemInactive lipgloss.Style
+	RefItemBranch   lipgloss.Style
+	RefItemTag      lipgloss.Style
+
+	TreeItemSelector lipgloss.Style
+	TreeItemActive   lipgloss.Style
+	TreeItemInactive lipgloss.Style
+	TreeFileDir      lipgloss.Style
+	TreeFileMode     lipgloss.Style
+	TreeFileSize     lipgloss.Style
+	TreeFileContent  lipgloss.Style
 }
 
 // DefaultStyles returns default styles for the TUI.
@@ -133,6 +160,11 @@ func DefaultStyles() *Styles {
 	s.Footer = lipgloss.NewStyle().
 		MarginTop(1)
 
+	s.Branch = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("203")).
+		Background(lipgloss.Color("236")).
+		Padding(0, 1)
+
 	s.HelpKey = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("241"))
 
@@ -157,5 +189,68 @@ func DefaultStyles() *Styles {
 		MarginLeft(2).
 		Width(52) // for now
 
+	s.LogItemInactive = lipgloss.NewStyle().
+		MarginLeft(1)
+
+	s.LogItemSelector = s.LogItemInactive.Copy().
+		Width(1).
+		Foreground(lipgloss.Color("#B083EA"))
+
+	s.LogItemActive = s.LogItemInactive.Copy().
+		Bold(true)
+
+	s.LogItemHash = s.LogItemInactive.Copy().
+		Width(7).
+		Foreground(lipgloss.Color("#A3A322"))
+
+	s.LogCommit = lipgloss.NewStyle().
+		Margin(0, 2)
+
+	s.LogCommitHash = s.LogItemHash.Copy().
+		UnsetMarginLeft().
+		UnsetWidth().
+		Bold(true)
+
+	s.LogCommitBody = lipgloss.NewStyle().
+		MarginTop(1).
+		MarginLeft(2)
+
+	s.LogCommitStatsAdd = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#00D787")).
+		Bold(true)
+
+	s.LogCommitStatsDel = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#FD5B5B")).
+		Bold(true)
+
+	s.RefItemSelector = s.LogItemSelector.Copy()
+
+	s.RefItemActive = s.LogItemActive.Copy()
+
+	s.RefItemInactive = s.LogItemInactive.Copy()
+
+	s.RefItemBranch = lipgloss.NewStyle()
+
+	s.RefItemTag = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#A3A322"))
+
+	s.TreeItemSelector = s.LogItemSelector.Copy()
+
+	s.TreeItemActive = s.LogItemActive.Copy()
+
+	s.TreeItemInactive = s.LogItemInactive.Copy()
+
+	s.TreeFileDir = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#00AAFF"))
+
+	s.TreeFileMode = s.LogItemInactive.Copy().
+		Width(10).
+		Foreground(lipgloss.Color("#777777"))
+
+	s.TreeFileSize = s.LogItemInactive.Copy().
+		Foreground(lipgloss.Color("252"))
+
+	s.TreeFileContent = lipgloss.NewStyle()
+
 	return s
 }