4fab795d33e3fda62eca59bc410bfe2b931bdde8

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat: new git tui

* 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

Diff

This diff is truncated to protect this page.

   1diff --git a/go.mod b/go.mod
   2index 6f6ef1df4b09317e733a0b16164bfad1ed0705d7..fad69374307dd3260c3b4900262899e57d855e71 100644
   3--- a/go.mod
   4+++ b/go.mod
   5@@ -3,8 +3,9 @@ module github.com/charmbracelet/soft-serve
   6 go 1.17
   7 
   8 require (
   9+	github.com/alecthomas/chroma v0.10.0
  10 	github.com/caarlos0/env/v6 v6.9.1
  11-	github.com/charmbracelet/bubbles v0.10.0
  12+	github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50
  13 	github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c
  14 	github.com/charmbracelet/glamour v0.4.0
  15 	github.com/charmbracelet/lipgloss v0.4.0
  16@@ -15,6 +16,8 @@ require (
  17 	github.com/go-git/go-git/v5 v5.4.2
  18 	github.com/matryer/is v1.2.0
  19 	github.com/muesli/reflow v0.3.0
  20+	github.com/muesli/termenv v0.11.0
  21+	github.com/sergi/go-diff v1.1.0 // indirect
  22 	golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce
  23 	gopkg.in/yaml.v2 v2.4.0
  24 )
  25@@ -23,8 +26,8 @@ require (
  26 	github.com/Microsoft/go-winio v0.5.1 // indirect
  27 	github.com/ProtonMail/go-crypto v0.0.0-20220113124808-70ae35bab23f // indirect
  28 	github.com/acomagu/bufpipe v1.0.3 // indirect
  29-	github.com/alecthomas/chroma v0.10.0 // indirect
  30 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
  31+	github.com/atotto/clipboard v0.1.4 // indirect
  32 	github.com/aymerick/douceur v0.2.0 // indirect
  33 	github.com/charmbracelet/keygen v0.1.2 // indirect
  34 	github.com/containerd/console v1.0.3 // indirect
  35@@ -42,10 +45,9 @@ require (
  36 	github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a // indirect
  37 	github.com/mitchellh/go-homedir v1.1.0 // indirect
  38 	github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
  39-	github.com/muesli/termenv v0.11.0 // indirect
  40 	github.com/olekukonko/tablewriter v0.0.5 // indirect
  41 	github.com/rivo/uniseg v0.2.0 // indirect
  42-	github.com/sergi/go-diff v1.2.0 // indirect
  43+	github.com/sahilm/fuzzy v0.1.0 // indirect
  44 	github.com/xanzy/ssh-agent v0.3.1 // indirect
  45 	github.com/yuin/goldmark v1.4.4 // indirect
  46 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
  47diff --git a/go.sum b/go.sum
  48index 5749f90ecd1569e09abab465c29ff7b08ecad062..12198bc644c5288da3d066fad5236b1f5a2240db 100644
  49--- a/go.sum
  50+++ b/go.sum
  51@@ -15,13 +15,16 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
  52 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
  53 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
  54 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
  55-github.com/atotto/clipboard v0.1.2/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
  56+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
  57+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
  58 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
  59 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
  60 github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
  61 github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
  62-github.com/charmbracelet/bubbles v0.10.0 h1:ZYqBwnmFGp91HSRRbhxKq5jr6bUPsVUBdkrGGWtv0Wk=
  63-github.com/charmbracelet/bubbles v0.10.0/go.mod h1:4tiDrWzH1MTD4t5NnrcthaedmI3MxU0FIutax7//dvk=
  64+github.com/charmbracelet/bubbles v0.10.3-0.20220206060452-06358c35f974 h1:mdlhYomJ2+TRS+py0WxxqjDdyjvX5ox+CyrCjV2HaUM=
  65+github.com/charmbracelet/bubbles v0.10.3-0.20220206060452-06358c35f974/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
  66+github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50 h1:hAsXGdqKHVoEbBlvReSfz8X605xddHMBFSxSrCaSSO4=
  67+github.com/charmbracelet/bubbles v0.10.3-0.20220208194203-1d489252fe50/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
  68 github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=
  69 github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c h1:hcS4xdVQwblKo8xuA5gRO/jql+yCVfnBlOwWcZrxOmA=
  70 github.com/charmbracelet/bubbletea v0.19.4-0.20220208181305-42cd4c31919c/go.mod h1:5nPeULOIxbAMykb3ggwhw1kruS7nP+Y4Za9yEH4J27U=
  71@@ -30,7 +33,6 @@ github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5Uy
  72 github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
  73 github.com/charmbracelet/keygen v0.1.2 h1:Gr/gdIOjDIxCTRVXpwa9tsXPoJPS2eGNehPoMnZLvTQ=
  74 github.com/charmbracelet/keygen v0.1.2/go.mod h1:kFQ3Cvop12fXWX1K29vxDxV9x8ujG4wBSXq//GySSSk=
  75-github.com/charmbracelet/lipgloss v0.3.0/go.mod h1:VkhdBS2eNAmRkTwRKLJCFhCOVkjntMusBDxv7TXahuk=
  76 github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g=
  77 github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM=
  78 github.com/charmbracelet/wish v0.2.1-0.20220208182816-534842b53d2a h1:dDdOcIedpXZ13xGfwFDd1ZlTUXotX945xXtz+7rHBK8=
  79@@ -81,12 +83,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
  80 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
  81 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
  82 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
  83+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
  84 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
  85 github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
  86 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
  87 github.com/matryer/is v1.2.0 h1:92UTHpy8CDwaJ08GqLDzhhuixiBUUD1p3AU6PHddz4A=
  88 github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA=
  89-github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
  90 github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
  91 github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
  92 github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
  93@@ -107,7 +109,6 @@ github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBc
  94 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
  95 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
  96 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
  97-github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
  98 github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
  99 github.com/muesli/termenv v0.11.0 h1:fwNUbu2mfWlgicwG7qYzs06aOI8Z/zKPAv8J4uKbT+o=
 100 github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
 101@@ -122,10 +123,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 102 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 103 github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 104 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
 105+github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 106 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 107+github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 108 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 109-github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
 110-github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 111 github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
 112 github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 113 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 114diff --git a/internal/git/git.go b/internal/git/git.go
 115index 936cf080cac33e780cf487639679b0b7fc1b2f5e..16931421dbddafa113dff031539d5b14fc901f1a 100644
 116--- a/internal/git/git.go
 117+++ b/internal/git/git.go
 118@@ -25,6 +25,7 @@ type Repo struct {
 119 	Repository  *git.Repository
 120 	Readme      string
 121 	LastUpdated *time.Time
 122+	commits     CommitLog
 123 }
 124 
 125 // RepoCommit contains metadata for a Git commit.
 126@@ -44,10 +45,9 @@ func (cl CommitLog) Less(i, j int) bool {
 127 
 128 // RepoSource is a reference to an on-disk repositories.
 129 type RepoSource struct {
 130-	Path    string
 131-	mtx     sync.Mutex
 132-	repos   []*Repo
 133-	commits CommitLog
 134+	Path  string
 135+	mtx   sync.Mutex
 136+	repos []*Repo
 137 }
 138 
 139 // NewRepoSource creates a new RepoSource.
 140@@ -106,14 +106,14 @@ func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
 141 	return r, nil
 142 }
 143 
 144-// GetCommits returns commits for the repository.
 145-func (rs *RepoSource) GetCommits(limit int) []RepoCommit {
 146-	rs.mtx.Lock()
 147-	defer rs.mtx.Unlock()
 148-	if limit > len(rs.commits) {
 149-		limit = len(rs.commits)
 150+func (r *Repo) GetCommits(limit int) CommitLog {
 151+	if limit <= 0 {
 152+		return r.commits
 153+	}
 154+	if limit > len(r.commits) {
 155+		limit = len(r.commits)
 156 	}
 157-	return rs.commits[:limit]
 158+	return r.commits[:limit]
 159 }
 160 
 161 // LoadRepos opens Git repositories.
 162@@ -125,7 +125,6 @@ func (rs *RepoSource) LoadRepos() error {
 163 		return err
 164 	}
 165 	rs.repos = make([]*Repo, 0)
 166-	rs.commits = make([]RepoCommit, 0)
 167 	for _, de := range rd {
 168 		rn := de.Name()
 169 		rg, err := git.PlainOpen(filepath.Join(rs.Path, rn))
 170@@ -143,6 +142,7 @@ func (rs *RepoSource) LoadRepos() error {
 171 
 172 func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) {
 173 	r := &Repo{Name: name}
 174+	r.commits = make([]RepoCommit, 0)
 175 	r.Repository = rg
 176 	l, err := rg.Log(&git.LogOptions{All: true})
 177 	if err != nil {
 178@@ -159,13 +159,13 @@ func (rs *RepoSource) loadRepo(name string, rg *git.Repository) (*Repo, error) {
 179 				}
 180 			}
 181 		}
 182-		rs.commits = append(rs.commits, RepoCommit{Name: name, Commit: c})
 183+		r.commits = append(r.commits, RepoCommit{Name: name, Commit: c})
 184 		return nil
 185 	})
 186 	if err != nil {
 187 		return nil, err
 188 	}
 189-	sort.Sort(rs.commits)
 190+	sort.Sort(r.commits)
 191 	return r, nil
 192 }
 193 
 194diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go
 195index c5071c4b836ed6e54388cb6238e42d29cab6a7ad..1adf41728ba4b9d584e30e8fb42fcf212be2a54b 100644
 196--- a/internal/tui/bubble.go
 197+++ b/internal/tui/bubble.go
 198@@ -7,12 +7,17 @@ import (
 199 	tea "github.com/charmbracelet/bubbletea"
 200 	"github.com/charmbracelet/lipgloss"
 201 	"github.com/charmbracelet/soft-serve/internal/config"
 202+	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 203 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
 204 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
 205 	"github.com/charmbracelet/soft-serve/internal/tui/style"
 206 	"github.com/gliderlabs/ssh"
 207 )
 208 
 209+const (
 210+	repoNameMaxWidth = 32
 211+)
 212+
 213 type sessionState int
 214 
 215 const (
 216@@ -83,14 +88,6 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 217 			return b, tea.Quit
 218 		case "tab", "shift+tab":
 219 			b.activeBox = (b.activeBox + 1) % 2
 220-		case "h", "left":
 221-			if b.activeBox > 0 {
 222-				b.activeBox--
 223-			}
 224-		case "l", "right":
 225-			if b.activeBox < len(b.boxes)-1 {
 226-				b.activeBox++
 227-			}
 228 		}
 229 	case errMsg:
 230 		b.error = msg.Error()
 231@@ -112,11 +109,8 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 232 	case selection.SelectedMsg:
 233 		b.activeBox = 1
 234 		rb := b.repoMenu[msg.Index].bubble
 235-		rb.GotoTop()
 236 		b.boxes[1] = rb
 237 	case selection.ActiveMsg:
 238-		rb := b.repoMenu[msg.Index].bubble
 239-		rb.GotoTop()
 240 		b.boxes[1] = b.repoMenu[msg.Index].bubble
 241 		cmds = append(cmds, func() tea.Msg {
 242 			return b.lastResize
 243@@ -163,27 +157,42 @@ func (b Bubble) headerView() string {
 244 
 245 func (b Bubble) footerView() string {
 246 	w := &strings.Builder{}
 247-	var h []helpEntry
 248-	switch b.state {
 249-	case errorState:
 250-		h = []helpEntry{{"q", "quit"}}
 251-	default:
 252-		h = []helpEntry{
 253+	var h []gittypes.HelpEntry
 254+	if b.state != errorState {
 255+		h = []gittypes.HelpEntry{
 256 			{"tab", "section"},
 257-			{"↑/↓", "navigate"},
 258-			{"q", "quit"},
 259 		}
 260-		if _, ok := b.boxes[b.activeBox].(*repo.Bubble); ok {
 261-			h = append(h[:2], helpEntry{"f/b", "pgdown/pgup"}, h[2])
 262+		if box, ok := b.boxes[b.activeBox].(gittypes.HelpableBubble); ok {
 263+			help := box.Help()
 264+			for _, he := range help {
 265+				h = append(h, he)
 266+			}
 267 		}
 268 	}
 269+	h = append(h, gittypes.HelpEntry{"q", "quit"})
 270 	for i, v := range h {
 271-		fmt.Fprint(w, v.Render(b.styles))
 272+		fmt.Fprint(w, helpEntryRender(v, b.styles))
 273 		if i != len(h)-1 {
 274 			fmt.Fprint(w, b.styles.HelpDivider)
 275 		}
 276 	}
 277-	return b.styles.Footer.Copy().Width(b.width).Render(w.String())
 278+	branch := ""
 279+	if b.state == loadedState {
 280+		branch = b.boxes[1].(*repo.Bubble).Reference().Short()
 281+	}
 282+	help := w.String()
 283+	branchMaxWidth := b.width - // bubble width
 284+		lipgloss.Width(help) - // help width
 285+		b.styles.App.GetHorizontalFrameSize() // App paddings
 286+	branch = b.styles.Branch.Render(gittypes.TruncateString(branch, branchMaxWidth-1, "…"))
 287+	gap := lipgloss.NewStyle().
 288+		Width(b.width -
 289+			lipgloss.Width(help) -
 290+			lipgloss.Width(branch) -
 291+			b.styles.App.GetHorizontalFrameSize()).
 292+		Render("")
 293+	footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch)
 294+	return b.styles.Footer.Render(footer)
 295 }
 296 
 297 func (b Bubble) errorView() string {
 298diff --git a/internal/tui/bubbles/commits/bubble.go b/internal/tui/bubbles/commits/bubble.go
 299deleted file mode 100644
 300index 4edc6a6c21e6a0a59c90695dfc6b1dc76e7aea2c..0000000000000000000000000000000000000000
 301--- a/internal/tui/bubbles/commits/bubble.go
 302+++ /dev/null
 303@@ -1,67 +0,0 @@
 304-package commits
 305-
 306-import (
 307-	"strings"
 308-
 309-	"github.com/charmbracelet/bubbles/viewport"
 310-	tea "github.com/charmbracelet/bubbletea"
 311-	"github.com/charmbracelet/soft-serve/internal/git"
 312-	"github.com/dustin/go-humanize"
 313-)
 314-
 315-type Bubble struct {
 316-	Commits  []git.RepoCommit
 317-	Height   int
 318-	Width    int
 319-	viewport viewport.Model
 320-}
 321-
 322-func NewBubble(height int, width int, rcs []git.RepoCommit) *Bubble {
 323-	b := &Bubble{
 324-		Commits:  rcs,
 325-		viewport: viewport.Model{Height: height, Width: width},
 326-	}
 327-	s := ""
 328-	for _, rc := range rcs {
 329-		s += b.renderCommit(rc) + "\n"
 330-	}
 331-	b.viewport.SetContent(s)
 332-	return b
 333-}
 334-
 335-func (b *Bubble) Init() tea.Cmd {
 336-	return nil
 337-}
 338-
 339-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 340-	cmds := make([]tea.Cmd, 0)
 341-	switch msg := msg.(type) {
 342-	case tea.KeyMsg:
 343-		switch msg.String() {
 344-		case "up", "k":
 345-			b.viewport.LineUp(1)
 346-		case "down", "j":
 347-			b.viewport.LineDown(1)
 348-		}
 349-	}
 350-	return b, tea.Batch(cmds...)
 351-}
 352-
 353-func (b *Bubble) renderCommit(rc git.RepoCommit) string {
 354-	s := ""
 355-	s += commitRepoNameStyle.Render(rc.Name)
 356-	s += " "
 357-	s += commitDateStyle.Render(humanize.Time(rc.Commit.Author.When))
 358-	s += "\n"
 359-	s += commitCommentStyle.Render(strings.TrimSpace(rc.Commit.Message))
 360-	s += "\n"
 361-	s += commitAuthorStyle.Render(rc.Commit.Author.Name)
 362-	s += " "
 363-	s += commitAuthorEmailStyle.Render(rc.Commit.Author.Email)
 364-	s += " "
 365-	return commitBoxStyle.Width(b.viewport.Width).Render(s)
 366-}
 367-
 368-func (b *Bubble) View() string {
 369-	return b.viewport.View()
 370-}
 371diff --git a/internal/tui/bubbles/commits/style.go b/internal/tui/bubbles/commits/style.go
 372deleted file mode 100644
 373index dd2543acb875d62ff769efb5ab39bc204b183d22..0000000000000000000000000000000000000000
 374--- a/internal/tui/bubbles/commits/style.go
 375+++ /dev/null
 376@@ -1,26 +0,0 @@
 377-package commits
 378-
 379-import (
 380-	"github.com/charmbracelet/lipgloss"
 381-)
 382-
 383-var commitBoxStyle = lipgloss.NewStyle().
 384-	Foreground(lipgloss.Color("#FFFFFF")).
 385-	BorderStyle(lipgloss.RoundedBorder()).
 386-	BorderForeground(lipgloss.Color("#670083")).
 387-	Padding(1)
 388-var commitRepoNameStyle = lipgloss.NewStyle().
 389-	Foreground(lipgloss.Color("#8922A5"))
 390-var commitAuthorStyle = lipgloss.NewStyle().
 391-	Foreground(lipgloss.Color("#670083"))
 392-var commitAuthorEmailStyle = lipgloss.NewStyle().
 393-	Foreground(lipgloss.Color("#781194"))
 394-var commitDateStyle = lipgloss.NewStyle().
 395-	Foreground(lipgloss.Color("#781194"))
 396-var commitCommentStyle = lipgloss.NewStyle().
 397-	Foreground(lipgloss.Color("#A0A0A0")).
 398-	BorderStyle(lipgloss.Border{Left: ">"}).
 399-	BorderForeground(lipgloss.Color("#606060")).
 400-	PaddingLeft(1).
 401-	PaddingBottom(0).
 402-	Margin(0)
 403diff --git a/internal/tui/bubbles/git/about/bubble.go b/internal/tui/bubbles/git/about/bubble.go
 404new file mode 100644
 405index 0000000000000000000000000000000000000000..07fa8a8c1775e571f0266437e7152d90e1af767a
 406--- /dev/null
 407+++ b/internal/tui/bubbles/git/about/bubble.go
 408@@ -0,0 +1,121 @@
 409+package about
 410+
 411+import (
 412+	"github.com/charmbracelet/bubbles/viewport"
 413+	tea "github.com/charmbracelet/bubbletea"
 414+	"github.com/charmbracelet/glamour"
 415+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 416+	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 417+	"github.com/charmbracelet/soft-serve/internal/tui/style"
 418+	"github.com/muesli/reflow/wrap"
 419+)
 420+
 421+type Bubble struct {
 422+	readmeViewport *vp.ViewportBubble
 423+	repo           types.Repo
 424+	styles         *style.Styles
 425+	height         int
 426+	heightMargin   int
 427+	width          int
 428+	widthMargin    int
 429+}
 430+
 431+func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
 432+	b := &Bubble{
 433+		readmeViewport: &vp.ViewportBubble{
 434+			Viewport: &viewport.Model{},
 435+		},
 436+		repo:         repo,
 437+		styles:       styles,
 438+		widthMargin:  wm,
 439+		heightMargin: hm,
 440+	}
 441+	b.SetSize(width, height)
 442+	return b
 443+}
 444+func (b *Bubble) Init() tea.Cmd {
 445+	return b.setupCmd
 446+}
 447+
 448+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 449+	var cmds []tea.Cmd
 450+	switch msg := msg.(type) {
 451+	case tea.WindowSizeMsg:
 452+		b.SetSize(msg.Width, msg.Height)
 453+		// XXX: if we find that longer readmes take more than a few
 454+		// milliseconds to render we may need to move Glamour rendering into a
 455+		// command.
 456+		md, err := b.glamourize(b.repo.GetReadme())
 457+		if err != nil {
 458+			return b, nil
 459+		}
 460+		b.readmeViewport.Viewport.SetContent(md)
 461+	case tea.KeyMsg:
 462+		switch msg.String() {
 463+		case "A":
 464+			b.GotoTop()
 465+		}
 466+	}
 467+	rv, cmd := b.readmeViewport.Update(msg)
 468+	b.readmeViewport = rv.(*vp.ViewportBubble)
 469+	cmds = append(cmds, cmd)
 470+	return b, tea.Batch(cmds...)
 471+}
 472+
 473+func (b *Bubble) SetSize(w, h int) {
 474+	b.width = w
 475+	b.height = h
 476+	b.readmeViewport.Viewport.Width = w - b.widthMargin
 477+	b.readmeViewport.Viewport.Height = h - b.heightMargin
 478+}
 479+
 480+func (b *Bubble) GotoTop() {
 481+	b.readmeViewport.Viewport.GotoTop()
 482+}
 483+
 484+func (b *Bubble) View() string {
 485+	return b.readmeViewport.View()
 486+}
 487+
 488+func (b *Bubble) Help() []types.HelpEntry {
 489+	return []types.HelpEntry{
 490+		{"f/b", "pgup/pgdown"},
 491+	}
 492+}
 493+
 494+func (b *Bubble) setupCmd() tea.Msg {
 495+	md, err := b.glamourize(b.repo.GetReadme())
 496+	if err != nil {
 497+		return types.ErrMsg{err}
 498+	}
 499+	b.readmeViewport.Viewport.SetContent(md)
 500+	b.GotoTop()
 501+	return nil
 502+}
 503+
 504+func (b *Bubble) glamourize(md string) (string, error) {
 505+	w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize()
 506+	if w > types.GlamourMaxWidth {
 507+		w = types.GlamourMaxWidth
 508diff --git a/internal/tui/bubbles/git/bubble.go b/internal/tui/bubbles/git/bubble.go
 509new file mode 100644
 510index 0000000000000000000000000000000000000000..c6780f26ab56b0015159abb9bc8772fdab59da1c
 511--- /dev/null
 512+++ b/internal/tui/bubbles/git/bubble.go
 513@@ -0,0 +1,157 @@
 514+package git
 515+
 516+import (
 517+	tea "github.com/charmbracelet/bubbletea"
 518+	"github.com/charmbracelet/lipgloss"
 519+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/about"
 520+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/log"
 521+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/refs"
 522+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/tree"
 523+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 524+	"github.com/charmbracelet/soft-serve/internal/tui/style"
 525+	"github.com/go-git/go-git/v5/plumbing"
 526+)
 527+
 528+const (
 529+	repoNameMaxWidth = 32
 530+)
 531+
 532+type pageState int
 533+
 534+const (
 535+	aboutPage pageState = iota
 536+	refsPage
 537+	logPage
 538+	treePage
 539+)
 540+
 541+type Bubble struct {
 542+	state        pageState
 543+	repo         types.Repo
 544+	height       int
 545+	heightMargin int
 546+	width        int
 547+	widthMargin  int
 548+	style        *style.Styles
 549+	boxes        []tea.Model
 550+}
 551+
 552+func NewBubble(repo types.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
 553+	b := &Bubble{
 554+		repo:         repo,
 555+		state:        aboutPage,
 556+		width:        width,
 557+		widthMargin:  wm,
 558+		height:       height,
 559+		heightMargin: hm,
 560+		style:        styles,
 561+		boxes:        make([]tea.Model, 4),
 562+	}
 563+	heightMargin := hm + lipgloss.Height(b.headerView())
 564+	b.boxes[aboutPage] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
 565+	b.boxes[refsPage] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
 566+	b.boxes[logPage] = log.NewBubble(repo, b.style, width, wm, height, heightMargin)
 567+	b.boxes[treePage] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin)
 568+	return b
 569+}
 570+
 571+func (b *Bubble) Init() tea.Cmd {
 572+	return b.setupCmd
 573+}
 574+
 575+func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 576+	cmds := make([]tea.Cmd, 0)
 577+	switch msg := msg.(type) {
 578+	case tea.KeyMsg:
 579+		switch msg.String() {
 580+		case "A":
 581+			b.state = aboutPage
 582+		case "R":
 583+			b.state = refsPage
 584+		case "L":
 585+			b.state = logPage
 586+		case "T":
 587+			b.state = treePage
 588+		}
 589+	case tea.WindowSizeMsg:
 590+		b.width = msg.Width
 591+		b.height = msg.Height
 592+		for i, bx := range b.boxes {
 593+			m, cmd := bx.Update(msg)
 594+			b.boxes[i] = m
 595+			if cmd != nil {
 596+				cmds = append(cmds, cmd)
 597+			}
 598+		}
 599+	}
 600+	m, cmd := b.boxes[b.state].Update(msg)
 601+	b.boxes[b.state] = m
 602+	if cmd != nil {
 603+		cmds = append(cmds, cmd)
 604+	}
 605+
 606+	switch msg := msg.(type) {
 607+	case tea.KeyMsg:
 608+		switch msg.String() {
 609+		case "enter":
 610+			if b.state == refsPage {
 611+				b.state = treePage
 612+				cmds = append(cmds, b.boxes[b.state].Init())
 613diff --git a/internal/tui/bubbles/git/log/bubble.go b/internal/tui/bubbles/git/log/bubble.go
 614new file mode 100644
 615index 0000000000000000000000000000000000000000..ccee7889ceb2c211982faf8815dccbd74f2c691a
 616--- /dev/null
 617+++ b/internal/tui/bubbles/git/log/bubble.go
 618@@ -0,0 +1,394 @@
 619+package log
 620+
 621+import (
 622+	"context"
 623+	"fmt"
 624+	"io"
 625+	"math"
 626+	"strings"
 627+	"time"
 628+
 629+	"github.com/charmbracelet/bubbles/list"
 630+	"github.com/charmbracelet/bubbles/viewport"
 631+	tea "github.com/charmbracelet/bubbletea"
 632+	gansi "github.com/charmbracelet/glamour/ansi"
 633+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 634+	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 635+	"github.com/charmbracelet/soft-serve/internal/tui/style"
 636+	"github.com/dustin/go-humanize/english"
 637+	"github.com/go-git/go-git/v5/plumbing/object"
 638+)
 639+
 640+var (
 641+	diffChroma = &gansi.CodeBlockElement{
 642+		Code:     "",
 643+		Language: "diff",
 644+	}
 645+)
 646+
 647+type commitMsg struct {
 648+	commit     *object.Commit
 649+	parent     *object.Commit
 650+	tree       *object.Tree
 651+	parentTree *object.Tree
 652+	patch      *object.Patch
 653+}
 654+
 655+type sessionState int
 656+
 657+const (
 658+	logState sessionState = iota
 659+	commitState
 660+	errorState
 661+)
 662+
 663+type item struct {
 664+	*types.Commit
 665+}
 666+
 667+func (i item) Title() string {
 668+	lines := strings.Split(i.Message, "\n")
 669+	if len(lines) > 0 {
 670+		return lines[0]
 671+	}
 672+	return ""
 673+}
 674+
 675+func (i item) FilterValue() string { return i.Title() }
 676+
 677+type itemDelegate struct {
 678+	style *style.Styles
 679+}
 680+
 681+func (d itemDelegate) Height() int                               { return 1 }
 682+func (d itemDelegate) Spacing() int                              { return 0 }
 683+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 684+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 685+	i, ok := listItem.(item)
 686+	if !ok {
 687+		return
 688+	}
 689+
 690+	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
 691+		d.style.LogItemSelector.GetWidth() +
 692+		d.style.LogItemHash.GetMarginLeft() +
 693+		d.style.LogItemHash.GetWidth() +
 694+		d.style.LogItemInactive.GetMarginLeft()
 695+	title := types.TruncateString(i.Title(), m.Width()-leftMargin, "…")
 696+	if index == m.Index() {
 697+		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
 698+			d.style.LogItemHash.Bold(true).Render(i.Hash.String()[:7])+
 699+			d.style.LogItemActive.Render(title))
 700+	} else {
 701+		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
 702+			d.style.LogItemHash.Render(i.Hash.String()[:7])+
 703+			d.style.LogItemInactive.Render(title))
 704+	}
 705+}
 706+
 707+type Bubble struct {
 708+	repo           types.Repo
 709+	list           list.Model
 710+	state          sessionState
 711+	commitViewport *vp.ViewportBubble
 712+	style          *style.Styles
 713+	width          int
 714+	widthMargin    int
 715+	height         int
 716+	heightMargin   int
 717+	error          types.ErrMsg
 718diff --git a/internal/tui/bubbles/git/refs/bubble.go b/internal/tui/bubbles/git/refs/bubble.go
 719new file mode 100644
 720index 0000000000000000000000000000000000000000..543410b4741d81b021ddf09b90b794eb0fa24460
 721--- /dev/null
 722+++ b/internal/tui/bubbles/git/refs/bubble.go
 723@@ -0,0 +1,174 @@
 724+package refs
 725+
 726+import (
 727+	"fmt"
 728+	"io"
 729+	"sort"
 730+
 731+	"github.com/charmbracelet/bubbles/list"
 732+	tea "github.com/charmbracelet/bubbletea"
 733+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 734+	"github.com/charmbracelet/soft-serve/internal/tui/style"
 735+	"github.com/go-git/go-git/v5/plumbing"
 736+)
 737+
 738+type item struct {
 739+	*plumbing.Reference
 740+}
 741+
 742+func (i item) Short() string {
 743+	return i.Name().Short()
 744+}
 745+
 746+func (i item) FilterValue() string { return i.Short() }
 747+
 748+type items []item
 749+
 750+func (cl items) Len() int      { return len(cl) }
 751+func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 752+func (cl items) Less(i, j int) bool {
 753+	return cl[i].Name().Short() < cl[j].Name().Short()
 754+}
 755+
 756+type itemDelegate struct {
 757+	style *style.Styles
 758+}
 759+
 760+func (d itemDelegate) Height() int                               { return 1 }
 761+func (d itemDelegate) Spacing() int                              { return 0 }
 762+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 763+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 764+	s := d.style
 765+	i, ok := listItem.(item)
 766+	if !ok {
 767+		return
 768+	}
 769+
 770+	ref := i.Short()
 771+	if i.Name().IsTag() {
 772+		ref = s.RefItemTag.Render(ref)
 773+	}
 774+	ref = s.RefItemBranch.Render(ref)
 775+	refMaxWidth := m.Width() -
 776+		s.RefItemSelector.GetMarginLeft() -
 777+		s.RefItemSelector.GetWidth() -
 778+		s.RefItemInactive.GetMarginLeft()
 779+	ref = types.TruncateString(ref, refMaxWidth, "…")
 780+	if index == m.Index() {
 781+		fmt.Fprint(w, s.RefItemSelector.Render(">")+
 782+			s.RefItemActive.Render(ref))
 783+	} else {
 784+		fmt.Fprint(w, s.LogItemSelector.Render(" ")+
 785+			s.RefItemInactive.Render(ref))
 786+	}
 787+}
 788+
 789+type Bubble struct {
 790+	repo         types.Repo
 791+	list         list.Model
 792+	style        *style.Styles
 793+	width        int
 794+	widthMargin  int
 795+	height       int
 796+	heightMargin int
 797+}
 798+
 799+func NewBubble(repo types.Repo, style *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
 800+	l := list.NewModel([]list.Item{}, itemDelegate{style}, width-widthMargin, height-heightMargin)
 801+	l.SetShowFilter(false)
 802+	l.SetShowHelp(false)
 803+	l.SetShowPagination(false)
 804+	l.SetShowStatusBar(false)
 805+	l.SetShowTitle(false)
 806+	l.SetFilteringEnabled(false)
 807+	l.DisableQuitKeybindings()
 808+	b := &Bubble{
 809+		repo:         repo,
 810+		style:        style,
 811+		width:        width,
 812+		height:       height,
 813+		widthMargin:  widthMargin,
 814+		heightMargin: heightMargin,
 815+		list:         l,
 816+	}
 817+	b.SetSize(width, height)
 818+	return b
 819+}
 820+
 821+func (b *Bubble) SetBranch(ref *plumbing.Reference) {
 822+	b.repo.SetReference(ref)
 823diff --git a/internal/tui/bubbles/git/tree/bubble.go b/internal/tui/bubbles/git/tree/bubble.go
 824new file mode 100644
 825index 0000000000000000000000000000000000000000..e69bb28d30bde231ec530e1abf68e8121f1553c6
 826--- /dev/null
 827+++ b/internal/tui/bubbles/git/tree/bubble.go
 828@@ -0,0 +1,355 @@
 829+package tree
 830+
 831+import (
 832+	"fmt"
 833+	"io"
 834+	"path/filepath"
 835+	"sort"
 836+	"strings"
 837+
 838+	"github.com/alecthomas/chroma/lexers"
 839+	"github.com/charmbracelet/bubbles/list"
 840+	"github.com/charmbracelet/bubbles/viewport"
 841+	tea "github.com/charmbracelet/bubbletea"
 842+	gansi "github.com/charmbracelet/glamour/ansi"
 843+	"github.com/charmbracelet/lipgloss"
 844+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
 845+	vp "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/viewport"
 846+	"github.com/charmbracelet/soft-serve/internal/tui/style"
 847+	"github.com/dustin/go-humanize"
 848+	"github.com/go-git/go-git/v5/plumbing"
 849+	"github.com/go-git/go-git/v5/plumbing/filemode"
 850+	"github.com/go-git/go-git/v5/plumbing/object"
 851+)
 852+
 853+type fileMsg struct {
 854+	content string
 855+}
 856+
 857+type sessionState int
 858+
 859+const (
 860+	treeState sessionState = iota
 861+	fileState
 862+	errorState
 863+)
 864+
 865+type item struct {
 866+	*object.TreeEntry
 867+	*object.File
 868+}
 869+
 870+func (i item) Name() string {
 871+	return i.TreeEntry.Name
 872+}
 873+
 874+func (i item) Mode() filemode.FileMode {
 875+	return i.TreeEntry.Mode
 876+}
 877+
 878+func (i item) FilterValue() string { return i.Name() }
 879+
 880+type items []item
 881+
 882+func (cl items) Len() int      { return len(cl) }
 883+func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 884+func (cl items) Less(i, j int) bool {
 885+	if cl[i].Mode() == filemode.Dir && cl[j].Mode() == filemode.Dir {
 886+		return cl[i].Name() < cl[j].Name()
 887+	} else if cl[i].Mode() == filemode.Dir {
 888+		return true
 889+	} else if cl[j].Mode() == filemode.Dir {
 890+		return false
 891+	} else {
 892+		return cl[i].Name() < cl[j].Name()
 893+	}
 894+}
 895+
 896+type itemDelegate struct {
 897+	style *style.Styles
 898+}
 899+
 900+func (d itemDelegate) Height() int                               { return 1 }
 901+func (d itemDelegate) Spacing() int                              { return 0 }
 902+func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
 903+func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
 904+	s := d.style
 905+	i, ok := listItem.(item)
 906+	if !ok {
 907+		return
 908+	}
 909+
 910+	name := i.Name()
 911+	if i.Mode() == filemode.Dir {
 912+		name = s.TreeFileDir.Render(name)
 913+	}
 914+	size := ""
 915+	if i.File != nil {
 916+		size = humanize.Bytes(uint64(i.File.Size))
 917+	}
 918+	var cs lipgloss.Style
 919+	mode, _ := i.Mode().ToOSFileMode()
 920+	if index == m.Index() {
 921+		cs = s.TreeItemActive
 922+		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
 923+	} else {
 924+		cs = s.TreeItemInactive
 925+		fmt.Fprint(w, s.TreeItemSelector.Render(" "))
 926+	}
 927+	leftMargin := s.TreeItemSelector.GetMarginLeft() +
 928diff --git a/internal/tui/bubbles/git/types/consts.go b/internal/tui/bubbles/git/types/consts.go
 929new file mode 100644
 930index 0000000000000000000000000000000000000000..940938638aa3082404ce339b8cdfbad01c52f7a2
 931--- /dev/null
 932+++ b/internal/tui/bubbles/git/types/consts.go
 933@@ -0,0 +1,28 @@
 934+package types
 935+
 936+import (
 937+	"time"
 938+
 939+	"github.com/charmbracelet/bubbles/key"
 940+)
 941+
 942+// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git
 943+
 944+const (
 945+	GlamourMaxWidth  = 120
 946+	RepoNameMaxWidth = 32
 947+	MaxDiffLines     = 1000
 948+	MaxDiffFiles     = 100
 949+	MaxPatchWait     = time.Second * 3
 950+)
 951+
 952+var (
 953+	PrevPage = key.NewBinding(
 954+		key.WithKeys("pgup", "b", "u"),
 955+		key.WithHelp("pgup", "prev page"),
 956+	)
 957+	NextPage = key.NewBinding(
 958+		key.WithKeys("pgdown", "f", "d"),
 959+		key.WithHelp("pgdn", "next page"),
 960+	)
 961+)
 962diff --git a/internal/tui/bubbles/git/types/error.go b/internal/tui/bubbles/git/types/error.go
 963new file mode 100644
 964index 0000000000000000000000000000000000000000..0e2cf1e4621a00626bba67d2eda23e5ab231c158
 965--- /dev/null
 966+++ b/internal/tui/bubbles/git/types/error.go
 967@@ -0,0 +1,36 @@
 968+package types
 969+
 970+import (
 971+	"errors"
 972+
 973+	"github.com/charmbracelet/lipgloss"
 974+	"github.com/charmbracelet/soft-serve/internal/tui/style"
 975+)
 976+
 977+var (
 978+	ErrDiffTooLong      = errors.New("diff is too long")
 979+	ErrDiffFilesTooLong = errors.New("diff files are too long")
 980+	ErrBinaryFile       = errors.New("binary file")
 981+	ErrFileTooLarge     = errors.New("file is too large")
 982+	ErrInvalidFile      = errors.New("invalid file")
 983+)
 984+
 985+type ErrMsg struct {
 986+	Err error
 987+}
 988+
 989+func (e ErrMsg) Error() string {
 990+	return e.Err.Error()
 991+}
 992+
 993+func (e ErrMsg) View(s *style.Styles) string {
 994+	return e.ViewWithPrefix(s, "")
 995+}
 996+
 997+func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string {
 998+	return lipgloss.JoinHorizontal(
 999+		lipgloss.Top,
1000+		s.ErrorTitle.Render(prefix),
1001+		s.ErrorBody.Render(e.Error()),
1002+	)
1003+}
1004diff --git a/internal/tui/bubbles/git/types/formatter.go b/internal/tui/bubbles/git/types/formatter.go
1005new file mode 100644
1006index 0000000000000000000000000000000000000000..7356581d19a61ada2c184b25d364e8c7c8b85ccb
1007--- /dev/null
1008+++ b/internal/tui/bubbles/git/types/formatter.go
1009@@ -0,0 +1,36 @@
1010+package types
1011+
1012+import (
1013+	"github.com/charmbracelet/glamour"
1014+	gansi "github.com/charmbracelet/glamour/ansi"
1015+	"github.com/muesli/termenv"
1016+)
1017+
1018+var (
1019+	RenderCtx = DefaultRenderCtx()
1020+	Styles    = DefaultStyles()
1021+)
1022+
1023+func DefaultStyles() gansi.StyleConfig {
1024+	noColor := ""
1025+	s := glamour.DarkStyleConfig
1026+	s.Document.StylePrimitive.Color = &noColor
1027+	s.CodeBlock.Chroma.Text.Color = &noColor
1028+	s.CodeBlock.Chroma.Name.Color = &noColor
1029+	return s
1030+}
1031+
1032+func DefaultRenderCtx() gansi.RenderContext {
1033+	return gansi.NewRenderContext(gansi.Options{
1034+		ColorProfile: termenv.TrueColor,
1035+		Styles:       DefaultStyles(),
1036+	})
1037+}
1038+
1039+func NewRenderCtx(worldwrap int) gansi.RenderContext {
1040+	return gansi.NewRenderContext(gansi.Options{
1041+		ColorProfile: termenv.TrueColor,
1042+		Styles:       DefaultStyles(),
1043+		WordWrap:     worldwrap,
1044+	})
1045+}
1046diff --git a/internal/tui/bubbles/git/types/git.go b/internal/tui/bubbles/git/types/git.go
1047new file mode 100644
1048index 0000000000000000000000000000000000000000..8e0213871b9bc7e868e15dfa9fd87b5a44fb88e7
1049--- /dev/null
1050+++ b/internal/tui/bubbles/git/types/git.go
1051@@ -0,0 +1,29 @@
1052+package types
1053+
1054+import (
1055+	"github.com/go-git/go-git/v5"
1056+	"github.com/go-git/go-git/v5/plumbing"
1057+	"github.com/go-git/go-git/v5/plumbing/object"
1058+)
1059+
1060+type Repo interface {
1061+	Name() string
1062+	GetReference() *plumbing.Reference
1063+	SetReference(*plumbing.Reference) error
1064+	GetReadme() string
1065+	GetCommits(limit int) (Commits, error)
1066+	Repository() *git.Repository
1067+	Tree(path string) (*object.Tree, error)
1068+}
1069+
1070+type Commit struct {
1071+	*object.Commit
1072+}
1073+
1074+type Commits []*Commit
1075+
1076+func (cl Commits) Len() int      { return len(cl) }
1077+func (cl Commits) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1078+func (cl Commits) Less(i, j int) bool {
1079+	return cl[i].Author.When.After(cl[j].Author.When)
1080+}
1081diff --git a/internal/tui/bubbles/git/types/help.go b/internal/tui/bubbles/git/types/help.go
1082new file mode 100644
1083index 0000000000000000000000000000000000000000..ae04818c76b15bd1914478fb5f2a45e7f23f0b04
1084--- /dev/null
1085+++ b/internal/tui/bubbles/git/types/help.go
1086@@ -0,0 +1,10 @@
1087+package types
1088+
1089+type HelpableBubble interface {
1090+	Help() []HelpEntry
1091+}
1092+
1093+type HelpEntry struct {
1094+	Key   string
1095+	Value string
1096+}
1097diff --git a/internal/tui/bubbles/git/types/utils.go b/internal/tui/bubbles/git/types/utils.go
1098new file mode 100644
1099index 0000000000000000000000000000000000000000..c1758097d235186a24d120676b42b667604f8fff
1100--- /dev/null
1101+++ b/internal/tui/bubbles/git/types/utils.go
1102@@ -0,0 +1,17 @@
1103+package types
1104+
1105+import "github.com/muesli/reflow/truncate"
1106+
1107+func TruncateString(s string, max int, tail string) string {
1108+	if max < 0 {
1109+		max = 0
1110+	}
1111+	return truncate.StringWithTail(s, uint(max), tail)
1112+}
1113+
1114+func Max(a, b int) int {
1115+	if a > b {
1116+		return a
1117+	}
1118+	return b
1119+}
1120diff --git a/internal/tui/bubbles/repo/viewport_patch.go b/internal/tui/bubbles/git/viewport/viewport_patch.go
1121rename from internal/tui/bubbles/repo/viewport_patch.go
1122rename to internal/tui/bubbles/git/viewport/viewport_patch.go
1123index df176ebebe429decac912802b7e98c21476a8d64..4163b5c40ee90025690f1f4d97d01ac12d310584 100644
1124--- a/internal/tui/bubbles/repo/viewport_patch.go
1125+++ b/internal/tui/bubbles/git/viewport/viewport_patch.go
1126@@ -1,4 +1,4 @@
1127-package repo
1128+package viewport
1129 
1130 import (
1131 	"github.com/charmbracelet/bubbles/viewport"
1132diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go
1133index 12f0ebbc237961c2510c3e6a0f72b07429e5bf82..d96d3b10ca718fa5689fe5307fffd32c1bf7c8d7 100644
1134--- a/internal/tui/bubbles/repo/bubble.go
1135+++ b/internal/tui/bubbles/repo/bubble.go
1136@@ -1,111 +1,71 @@
1137 package repo
1138 
1139 import (
1140-	"bytes"
1141 	"fmt"
1142 	"strconv"
1143-	"text/template"
1144 
1145-	"github.com/charmbracelet/bubbles/viewport"
1146 	tea "github.com/charmbracelet/bubbletea"
1147-	"github.com/charmbracelet/glamour"
1148-	"github.com/charmbracelet/glamour/ansi"
1149 	"github.com/charmbracelet/lipgloss"
1150-	"github.com/charmbracelet/soft-serve/internal/git"
1151+	gitui "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git"
1152+	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1153 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1154+	"github.com/go-git/go-git/v5/plumbing"
1155 	"github.com/muesli/reflow/truncate"
1156 	"github.com/muesli/reflow/wrap"
1157 )
1158 
1159 const (
1160-	glamourMaxWidth  = 120
1161 	repoNameMaxWidth = 32
1162 )
1163 
1164-var glamourStyle = func() ansi.StyleConfig {
1165-	noColor := ""
1166-	s := glamour.DarkStyleConfig
1167-	s.Document.StylePrimitive.Color = &noColor
1168-	s.CodeBlock.Chroma.Text.Color = &noColor
1169-	s.CodeBlock.Chroma.Name.Color = &noColor
1170-	return s
1171-}()
1172-
1173-type ErrMsg struct {
1174-	Error error
1175-}
1176-
1177 type Bubble struct {
1178-	templateObject interface{}
1179-	repoSource     *git.RepoSource
1180-	name           string
1181-	repo           *git.Repo
1182-	styles         *style.Styles
1183-	readmeViewport *ViewportBubble
1184-	readme         string
1185-	height         int
1186-	heightMargin   int
1187-	width          int
1188-	widthMargin    int
1189-	Active         bool
1190-
1191-	// XXX: ideally, we get these from the parent as a pointer. Currently, we
1192-	// can't add a *tui.Config because it's an illegal import cycle. One
1193-	// solution would be to (rename and) move this Bubble into the parent
1194-	// package.
1195-	Host string
1196-	Port int
1197+	name         string
1198+	host         string
1199+	port         int
1200+	repo         gittypes.Repo
1201+	styles       *style.Styles
1202+	width        int
1203+	widthMargin  int
1204+	height       int
1205+	heightMargin int
1206+	box          *gitui.Bubble
1207+
1208+	Active bool
1209 }
1210 
1211-func NewBubble(rs *git.RepoSource, name string, styles *style.Styles, width, wm, height, hm int, tmp interface{}) *Bubble {
1212+func NewBubble(name, host string, port int, repo gittypes.Repo, styles *style.Styles, width, wm, height, hm int) *Bubble {
1213 	b := &Bubble{
1214-		templateObject: tmp,
1215-		repoSource:     rs,
1216-		name:           name,
1217-		styles:         styles,
1218-		heightMargin:   hm,
1219-		widthMargin:    wm,
1220-		readmeViewport: &ViewportBubble{
1221-			Viewport: &viewport.Model{},
1222-		},
1223-	}
1224-	b.SetSize(width, height)
1225+		name:         name,
1226+		host:         host,
1227+		port:         port,
1228+		repo:         repo,
1229+		width:        width,
1230+		widthMargin:  wm,
1231+		height:       height,
1232+		heightMargin: hm,
1233+		styles:       styles,
1234+	}
1235+	b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize())
1236diff --git a/internal/tui/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go
1237index 6a8a59342651ac3ebfc0056a9328b17a0b75beb0..26730dd214f19f720e65b15cada8d88a3becb44f 100644
1238--- a/internal/tui/bubbles/selection/bubble.go
1239+++ b/internal/tui/bubbles/selection/bubble.go
1240@@ -5,6 +5,7 @@ import (
1241 
1242 	tea "github.com/charmbracelet/bubbletea"
1243 	"github.com/charmbracelet/lipgloss"
1244+	gittypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1245 	"github.com/charmbracelet/soft-serve/internal/tui/style"
1246 	"github.com/muesli/reflow/truncate"
1247 )
1248@@ -79,6 +80,12 @@ func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1249 	return b, tea.Batch(cmds...)
1250 }
1251 
1252+func (b *Bubble) Help() []gittypes.HelpEntry {
1253+	return []gittypes.HelpEntry{
1254+		{"↑/↓", "navigate"},
1255+	}
1256+}
1257+
1258 func (b *Bubble) sendActiveMessage() tea.Msg {
1259 	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
1260 		return ActiveMsg{
1261diff --git a/internal/tui/commands.go b/internal/tui/commands.go
1262index 6b98ca7d6d32279c8c27ab7e0826c1d3dde70e2a..d8879e0873903f25884d9d6a1f33a66b191deeeb 100644
1263--- a/internal/tui/commands.go
1264+++ b/internal/tui/commands.go
1265@@ -1,12 +1,14 @@
1266 package tui
1267 
1268 import (
1269+	"bytes"
1270 	"fmt"
1271+	"text/template"
1272 
1273 	tea "github.com/charmbracelet/bubbletea"
1274 	"github.com/charmbracelet/lipgloss"
1275-	"github.com/charmbracelet/soft-serve/internal/config"
1276-	br "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
1277+	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1278+	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
1279 	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
1280 	gm "github.com/charmbracelet/wish/git"
1281 )
1282@@ -93,37 +95,54 @@ func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) {
1283 	return mes, nil
1284 }
1285 
1286-func (b *Bubble) newMenuEntry(name string, repo string) (MenuEntry, error) {
1287-	var tmplConfig *config.Config
1288-	if repo == "config" {
1289-		tmplConfig = b.config
1290+func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
1291+	gr := &Repo{
1292+		name: rn,
1293+	}
1294+	me := MenuEntry{Name: name, Repo: rn}
1295+	r, err := b.config.Source.GetRepo(rn)
1296+	if err != nil {
1297+		return me, err
1298+	}
1299+	if rn == "config" {
1300+		md, err := templatize(r.Readme, b.config)
1301+		if err != nil {
1302+			return me, err
1303+		}
1304+		r.Readme = md
1305+	}
1306+	gr.repo = r.Repository
1307+	gr.readme = r.Readme
1308+	gr.ref, err = r.Repository.Head()
1309+	if err != nil {
1310+		return me, err
1311 	}
1312-	me := MenuEntry{Name: name, Repo: repo}
1313-	width := b.width
1314 	boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize()
1315 	// TODO: also send this along with a tea.WindowSizeMsg
1316 	var heightMargin = lipgloss.Height(b.headerView()) +
1317 		lipgloss.Height(b.footerView()) +
1318 		b.styles.RepoBody.GetVerticalFrameSize() +
1319 		b.styles.App.GetVerticalMargins()
1320-	rb := br.NewBubble(
1321-		b.config.Source,
1322-		me.Repo,
1323-		b.styles,
1324-		width,
1325-		boxLeftWidth,
1326-		b.height,
1327-		heightMargin,
1328-		tmplConfig,
1329-	)
1330-	rb.Host = b.config.Host
1331-	rb.Port = b.config.Port
1332+	rb := repo.NewBubble(rn, b.config.Host, b.config.Port, gr, b.styles, b.width, boxLeftWidth, b.height, heightMargin)
1333 	initCmd := rb.Init()
1334 	msg := initCmd()
1335 	switch msg := msg.(type) {
1336-	case br.ErrMsg:
1337-		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Error)
1338+	case gitypes.ErrMsg:
1339+		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error())
1340 	}
1341 	me.bubble = rb
1342 	return me, nil
1343 }
1344+
1345+func templatize(mdt string, tmpl interface{}) (string, error) {
1346+	t, err := template.New("readme").Parse(mdt)
1347+	if err != nil {
1348+		return "", err
1349+	}
1350+	buf := &bytes.Buffer{}
1351+	err = t.Execute(buf, tmpl)
1352+	if err != nil {
1353+		return "", err
1354+	}
1355+	return buf.String(), nil
1356+}
1357diff --git a/internal/tui/git.go b/internal/tui/git.go
1358new file mode 100644
1359index 0000000000000000000000000000000000000000..f8105a695317d734ddb4502ee04cb34dd3253968
1360--- /dev/null
1361+++ b/internal/tui/git.go
1362@@ -0,0 +1,105 @@
1363+package tui
1364+
1365+import (
1366+	"path/filepath"
1367+
1368+	gitypes "github.com/charmbracelet/soft-serve/internal/tui/bubbles/git/types"
1369+	"github.com/go-git/go-git/v5"
1370+	"github.com/go-git/go-git/v5/plumbing"
1371+	"github.com/go-git/go-git/v5/plumbing/object"
1372+)
1373+
1374+type Repo struct {
1375+	name   string
1376+	repo   *git.Repository
1377+	readme string
1378+	ref    *plumbing.Reference
1379+}
1380+
1381+func (r *Repo) Name() string {
1382+	return r.name
1383+}
1384+
1385+func (r *Repo) GetReference() *plumbing.Reference {
1386+	return r.ref
1387+}
1388+
1389+func (r *Repo) SetReference(ref *plumbing.Reference) error {
1390+	r.ref = ref
1391+	return nil
1392+}
1393+
1394+func (r *Repo) Repository() *git.Repository {
1395+	return r.repo
1396+}
1397+
1398+func (r *Repo) Tree(path string) (*object.Tree, error) {
1399+	path = filepath.Clean(path)
1400+	c, err := r.repo.CommitObject(r.ref.Hash())
1401+	if err != nil {
1402+		return nil, err
1403+	}
1404+	t, err := c.Tree()
1405+	if err != nil {
1406+		return nil, err
1407+	}
1408+	if path == "." {
1409+		return t, nil
1410+	}
1411+	return t.Tree(path)
1412+}
1413+
1414+func (r *Repo) GetCommits(limit int) (gitypes.Commits, error) {
1415+	commits := gitypes.Commits{}
1416+	l, err := r.repo.Log(&git.LogOptions{
1417+		Order: git.LogOrderCommitterTime,
1418+		From:  r.ref.Hash(),
1419+	})
1420+	if err != nil {
1421+		return nil, err
1422+	}
1423+	err = l.ForEach(func(c *object.Commit) error {
1424+		commits = append(commits, &gitypes.Commit{c})
1425+		return nil
1426+	})
1427+	if err != nil {
1428+		return nil, err
1429+	}
1430+	if limit <= 0 || limit > len(commits) {
1431+		limit = len(commits)
1432+	}
1433+	return commits[:limit], nil
1434+}
1435+
1436+func (r *Repo) GetReadme() string {
1437+	if r.readme != "" {
1438+		return r.readme
1439+	}
1440+	md, err := r.readFile("README.md")
1441+	if err != nil {
1442+		return ""
1443+	}
1444+	return md
1445+}
1446+
1447+func (r *Repo) readFile(path string) (string, error) {
1448+	lg, err := r.repo.Log(&git.LogOptions{
1449+		From: r.ref.Hash(),
1450+	})
1451+	if err != nil {
1452+		return "", err
1453+	}
1454+	c, err := lg.Next()
1455+	if err != nil {
1456+		return "", err
1457+	}
1458+	f, err := c.File(path)
1459+	if err != nil {
1460+		return "", err
1461+	}
1462diff --git a/internal/tui/session.go b/internal/tui/session.go
1463index c322d01c19d1ef53d6fba06442ffbd692caa6e35..2b6db15b1336a2742aac58444f4ff54c146a3b1e 100644
1464--- a/internal/tui/session.go
1465+++ b/internal/tui/session.go
1466@@ -2,6 +2,8 @@ package tui
1467 
1468 import (
1469 	"fmt"
1470+	"net/url"
1471+	"strings"
1472 
1473 	tea "github.com/charmbracelet/bubbletea"
1474 	"github.com/charmbracelet/soft-serve/internal/config"
1475@@ -16,6 +18,10 @@ func SessionHandler(cfg *config.Config) func(ssh.Session) (tea.Model, []tea.Prog
1476 		case 0:
1477 			scfg.InitialRepo = ""
1478 		case 1:
1479+			p, err := url.Parse(cmd[0])
1480+			if err != nil || strings.Contains(p.Path, "/") {
1481+				return nil, nil
1482+			}
1483 			scfg.InitialRepo = cmd[0]
1484 		default:
1485 			return nil, nil
1486diff --git a/internal/tui/style/style.go b/internal/tui/style/style.go
1487index ec09bd832dbd1cba88d8abdb3f69666e0c57ba75..1f57cb5b763cd83ea39c5add97521545ab2ac6eb 100644
1488--- a/internal/tui/style/style.go
1489+++ b/internal/tui/style/style.go
1490@@ -31,6 +31,7 @@ type Styles struct {
1491 	RepoBody     lipgloss.Style
1492 
1493 	Footer      lipgloss.Style
1494+	Branch      lipgloss.Style
1495 	HelpKey     lipgloss.Style
1496 	HelpValue   lipgloss.Style
1497 	HelpDivider lipgloss.Style
1498@@ -38,6 +39,32 @@ type Styles struct {
1499 	Error      lipgloss.Style
1500 	ErrorTitle lipgloss.Style
1501 	ErrorBody  lipgloss.Style
1502+
1503+	LogItemSelector   lipgloss.Style
1504+	LogItemActive     lipgloss.Style
1505+	LogItemInactive   lipgloss.Style
1506+	LogItemHash       lipgloss.Style
1507+	LogCommit         lipgloss.Style
1508+	LogCommitHash     lipgloss.Style
1509+	LogCommitAuthor   lipgloss.Style
1510+	LogCommitDate     lipgloss.Style
1511+	LogCommitBody     lipgloss.Style
1512+	LogCommitStatsAdd lipgloss.Style
1513+	LogCommitStatsDel lipgloss.Style
1514+
1515+	RefItemSelector lipgloss.Style
1516+	RefItemActive   lipgloss.Style
1517+	RefItemInactive lipgloss.Style
1518+	RefItemBranch   lipgloss.Style
1519+	RefItemTag      lipgloss.Style
1520+
1521+	TreeItemSelector lipgloss.Style
1522+	TreeItemActive   lipgloss.Style
1523+	TreeItemInactive lipgloss.Style
1524+	TreeFileDir      lipgloss.Style
1525+	TreeFileMode     lipgloss.Style
1526+	TreeFileSize     lipgloss.Style
1527+	TreeFileContent  lipgloss.Style
1528 }
1529 
1530 // DefaultStyles returns default styles for the TUI.
1531@@ -133,6 +160,11 @@ func DefaultStyles() *Styles {
1532 	s.Footer = lipgloss.NewStyle().
1533 		MarginTop(1)
1534 
1535+	s.Branch = lipgloss.NewStyle().
1536+		Foreground(lipgloss.Color("203")).
1537+		Background(lipgloss.Color("236")).
1538+		Padding(0, 1)
1539+
1540 	s.HelpKey = lipgloss.NewStyle().
1541 		Foreground(lipgloss.Color("241"))
1542 
1543@@ -157,5 +189,68 @@ func DefaultStyles() *Styles {
1544 		MarginLeft(2).
1545 		Width(52) // for now
1546 
1547+	s.LogItemInactive = lipgloss.NewStyle().
1548+		MarginLeft(1)
1549+
1550+	s.LogItemSelector = s.LogItemInactive.Copy().
1551+		Width(1).
1552+		Foreground(lipgloss.Color("#B083EA"))
1553+
1554+	s.LogItemActive = s.LogItemInactive.Copy().
1555+		Bold(true)
1556+
1557+	s.LogItemHash = s.LogItemInactive.Copy().
1558+		Width(7).
1559+		Foreground(lipgloss.Color("#A3A322"))
1560+
1561+	s.LogCommit = lipgloss.NewStyle().
1562+		Margin(0, 2)
1563+
1564+	s.LogCommitHash = s.LogItemHash.Copy().
1565+		UnsetMarginLeft().
1566+		UnsetWidth().
1567+		Bold(true)
1568+
1569+	s.LogCommitBody = lipgloss.NewStyle().
1570+		MarginTop(1).
1571+		MarginLeft(2)
1572+
1573+	s.LogCommitStatsAdd = lipgloss.NewStyle().
1574+		Foreground(lipgloss.Color("#00D787")).
1575+		Bold(true)
1576+
1577+	s.LogCommitStatsDel = lipgloss.NewStyle().
1578+		Foreground(lipgloss.Color("#FD5B5B")).
1579+		Bold(true)
1580+
1581+	s.RefItemSelector = s.LogItemSelector.Copy()
1582+
1583+	s.RefItemActive = s.LogItemActive.Copy()
1584+
1585+	s.RefItemInactive = s.LogItemInactive.Copy()
1586+
1587+	s.RefItemBranch = lipgloss.NewStyle()
1588+
1589+	s.RefItemTag = lipgloss.NewStyle().