02e1617b19312e900e2544c3b2fb5a9457ce3fa8

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

Message

feat: browse local repositories (#369)

* feat: browse local repositories

* refactor(ui): resolve race conditions and clean up code

* feat(ui): update readme based on selected reference

* feat(ui): add branch/tag commit date and hash

Fixes: https://github.com/charmbracelet/soft-serve/issues/382

* fix(ui): clean up statusbar logic

* fix(ui): cleanup statusbar and misc msgs

* fix(ui): preserve header line when no description is available

* fix(ui): match readme and list missing items styles

* fix(ui): header height calculation

Fixes: 43b4331f88ff (fix(ui): preserve header line when no description is available)

* fix(ui): truncate refitem msg

Fixes: fd0240995004 (feat(ui): add branch/tag commit date and hash)

* fix(ui): dry code line number and highlight formatting

* feat(ui): add blame file view

Fixes: https://github.com/charmbracelet/soft-serve/issues/149

* fix: lint errors

* fix(ui): NaN floats, analyse file content, and start spinner

* feat(ui): use right mouse click to go back in the files tab

* fix(ui): display "1" when there's only one page to display

* feat(ui): add stash view

Display repository stashed items

* feat: run browse on "soft"

Diff

This diff is truncated to protect this page.

   1diff --git a/cmd/soft/browse.go b/cmd/soft/browse.go
   2new file mode 100644
   3index 0000000000000000000000000000000000000000..324e7cb469be682d73a1c3501d251f9f66214009
   4--- /dev/null
   5+++ b/cmd/soft/browse.go
   6@@ -0,0 +1,301 @@
   7+package main
   8+
   9+import (
  10+	"fmt"
  11+	"path/filepath"
  12+	"time"
  13+
  14+	"github.com/charmbracelet/bubbles/key"
  15+	tea "github.com/charmbracelet/bubbletea"
  16+	"github.com/charmbracelet/lipgloss"
  17+	"github.com/charmbracelet/soft-serve/git"
  18+	"github.com/charmbracelet/soft-serve/server/proto"
  19+	"github.com/charmbracelet/soft-serve/server/ui/common"
  20+	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
  21+	"github.com/charmbracelet/soft-serve/server/ui/pages/repo"
  22+	"github.com/muesli/termenv"
  23+	"github.com/spf13/cobra"
  24+)
  25+
  26+var browseCmd = &cobra.Command{
  27+	Use:   "browse PATH",
  28+	Short: "Browse a repository",
  29+	Args:  cobra.MaximumNArgs(1),
  30+	RunE: func(cmd *cobra.Command, args []string) error {
  31+		rp := "."
  32+		if len(args) > 0 {
  33+			rp = args[0]
  34+		}
  35+
  36+		abs, err := filepath.Abs(rp)
  37+		if err != nil {
  38+			return err
  39+		}
  40+
  41+		r, err := git.Open(abs)
  42+		if err != nil {
  43+			return fmt.Errorf("failed to open repository: %w", err)
  44+		}
  45+
  46+		// Bubble Tea uses Termenv default output so we have to use the same
  47+		// thing here.
  48+		output := termenv.DefaultOutput()
  49+		ctx := cmd.Context()
  50+		c := common.NewCommon(ctx, output, 0, 0)
  51+		comps := []common.TabComponent{
  52+			repo.NewReadme(c),
  53+			repo.NewFiles(c),
  54+			repo.NewLog(c),
  55+		}
  56+		if !r.IsBare {
  57+			comps = append(comps, repo.NewStash(c))
  58+		}
  59+		comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags))
  60+		m := &model{
  61+			model:  repo.New(c, comps...),
  62+			repo:   repository{r},
  63+			common: c,
  64+		}
  65+
  66+		m.footer = footer.New(c, m)
  67+		p := tea.NewProgram(m,
  68+			tea.WithAltScreen(),
  69+			tea.WithMouseCellMotion(),
  70+		)
  71+
  72+		_, err = p.Run()
  73+		return err
  74+	},
  75+}
  76+
  77+func init() {
  78+	// HACK: This is a hack to hide the clone url
  79+	// TODO: Make this configurable
  80+	common.CloneCmd = func(publicURL, name string) string { return "" }
  81+	rootCmd.AddCommand(browseCmd)
  82+}
  83+
  84+type state int
  85+
  86+const (
  87+	startState state = iota
  88+	errorState
  89+)
  90+
  91+type model struct {
  92+	model      *repo.Repo
  93+	footer     *footer.Footer
  94+	repo       proto.Repository
  95+	common     common.Common
  96+	state      state
  97+	showFooter bool
  98+	error      error
  99+}
 100+
 101+var _ tea.Model = &model{}
 102+
 103+func (m *model) SetSize(w, h int) {
 104+	m.common.SetSize(w, h)
 105+	style := m.common.Styles.App.Copy()
 106diff --git a/cmd/soft/migrate_config.go b/cmd/soft/migrate_config.go
 107index 86bea23d39ad148efb7b5a0cea1f2d2663306fb4..5138e93c0e8195d7dc328d4dc830abdfa1afce63 100644
 108--- a/cmd/soft/migrate_config.go
 109+++ b/cmd/soft/migrate_config.go
 110@@ -124,7 +124,7 @@ var migrateConfig = &cobra.Command{
 111 			}
 112 		}
 113 
 114-		readme, readmePath, err := git.LatestFile(r, "README*")
 115+		readme, readmePath, err := git.LatestFile(r, nil, "README*")
 116 		hasReadme := err == nil
 117 
 118 		// Set server name
 119diff --git a/cmd/soft/root.go b/cmd/soft/root.go
 120index 31a1f78031a0e477841136cae5460c147bd38f87..e031a67a377a68c2f44a1277a74da17a5cc4d20c 100644
 121--- a/cmd/soft/root.go
 122+++ b/cmd/soft/root.go
 123@@ -33,6 +33,9 @@ var (
 124 		Short:        "A self-hostable Git server for the command line",
 125 		Long:         "Soft Serve is a self-hostable Git server for the command line.",
 126 		SilenceUsage: true,
 127+		RunE: func(cmd *cobra.Command, args []string) error {
 128+			return browseCmd.RunE(cmd, args)
 129+		},
 130 	}
 131 )
 132 
 133diff --git a/git/commit.go b/git/commit.go
 134index 3dd0a8ff377914166b3c66ca3b2748b4ee979605..1e955ff31b405a98faa7804e96fe4b1d93214002 100644
 135--- a/git/commit.go
 136+++ b/git/commit.go
 137@@ -4,27 +4,11 @@ import (
 138 	"github.com/gogs/git-module"
 139 )
 140 
 141-// ZeroHash is the zero hash.
 142-var ZeroHash Hash = git.EmptyID
 143-
 144-// Hash represents a git hash.
 145-type Hash string
 146-
 147-// String returns the string representation of a hash as a string.
 148-func (h Hash) String() string {
 149-	return string(h)
 150-}
 151-
 152-// SHA1 represents the hash as a SHA1.
 153-func (h Hash) SHA1() *git.SHA1 {
 154-	return git.MustIDFromString(h.String())
 155-}
 156+// ZeroID is the zero hash.
 157+const ZeroID = git.EmptyID
 158 
 159 // Commit is a wrapper around git.Commit with helper methods.
 160-type Commit struct {
 161-	*git.Commit
 162-	Hash Hash
 163-}
 164+type Commit = git.Commit
 165 
 166 // Commits is a list of commits.
 167 type Commits []*Commit
 168diff --git a/git/patch.go b/git/patch.go
 169index aaa6b9d78fb2692f530115e0038112bffa1dbdde..d166390cbef50227d022ed56ee144acaff0116c6 100644
 170--- a/git/patch.go
 171+++ b/git/patch.go
 172@@ -115,14 +115,14 @@ func (f *DiffFileChange) Mode() git.EntryMode {
 173 
 174 // Files returns the diff files.
 175 func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
 176-	if f.OldIndex != ZeroHash.String() {
 177+	if f.OldIndex != ZeroID {
 178 		from = &DiffFileChange{
 179 			hash: f.OldIndex,
 180 			name: f.OldName(),
 181 			mode: f.OldMode(),
 182 		}
 183 	}
 184-	if f.Index != ZeroHash.String() {
 185+	if f.Index != ZeroID {
 186 		to = &DiffFileChange{
 187 			hash: f.Index,
 188 			name: f.Name,
 189@@ -298,14 +298,14 @@ func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
 190 		lines = append(lines,
 191 			fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
 192 			fmt.Sprintf("new file mode %o", to.Mode()),
 193-			fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()),
 194+			fmt.Sprintf("index %s..%s", ZeroID, to.Hash()),
 195 		)
 196 		lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
 197 	case to == nil:
 198 		lines = append(lines,
 199 			fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
 200 			fmt.Sprintf("deleted file mode %o", from.Mode()),
 201-			fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash),
 202+			fmt.Sprintf("index %s..%s", from.Hash(), ZeroID),
 203 		)
 204 		lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
 205 	}
 206@@ -332,3 +332,24 @@ func (d *Diff) Patch() string {
 207 	}
 208 	return p.String()
 209 }
 210+
 211+func toDiff(ddiff *git.Diff) *Diff {
 212+	files := make([]*DiffFile, 0, len(ddiff.Files))
 213+	for _, df := range ddiff.Files {
 214+		sections := make([]*DiffSection, 0, len(df.Sections))
 215+		for _, ds := range df.Sections {
 216+			sections = append(sections, &DiffSection{
 217+				DiffSection: ds,
 218+			})
 219+		}
 220+		files = append(files, &DiffFile{
 221+			DiffFile: df,
 222+			Sections: sections,
 223+		})
 224+	}
 225+	diff := &Diff{
 226+		Diff:  ddiff,
 227+		Files: files,
 228+	}
 229+	return diff
 230+}
 231diff --git a/git/reference.go b/git/reference.go
 232index ec01a53fdc8c70f52283107c33029ae5fa57577d..47aceb95bbbec2577195fbf58512d0dd6adaa2be 100644
 233--- a/git/reference.go
 234+++ b/git/reference.go
 235@@ -18,23 +18,12 @@ const (
 236 // Reference is a wrapper around git.Reference with helper methods.
 237 type Reference struct {
 238 	*git.Reference
 239-	Hash Hash
 240 	path string // repo path
 241 }
 242 
 243 // ReferenceName is a Refspec wrapper.
 244 type ReferenceName string
 245 
 246-// NewReference creates a new reference.
 247-func NewReference(rp, refspec string) *Reference {
 248-	return &Reference{
 249-		Reference: &git.Reference{
 250-			Refspec: refspec,
 251-		},
 252-		path: rp,
 253-	}
 254-}
 255-
 256 // String returns the reference name i.e. refs/heads/master.
 257 func (r ReferenceName) String() string {
 258 	return string(r)
 259@@ -42,11 +31,7 @@ func (r ReferenceName) String() string {
 260 
 261 // Short returns the short name of the reference i.e. master.
 262 func (r ReferenceName) Short() string {
 263-	s := strings.Split(r.String(), "/")
 264-	if len(s) > 0 {
 265-		return s[len(s)-1]
 266-	}
 267-	return r.String()
 268+	return git.RefShortName(string(r))
 269 }
 270 
 271 // Name returns the reference name i.e. refs/heads/master.
 272@@ -63,14 +48,3 @@ func (r *Reference) IsBranch() bool {
 273 func (r *Reference) IsTag() bool {
 274 	return strings.HasPrefix(r.Refspec, git.RefsTags)
 275 }
 276-
 277-// TargetHash returns the hash of the reference target.
 278-func (r *Reference) TargetHash() Hash {
 279-	if r.IsTag() {
 280-		id, err := git.ShowRefVerify(r.path, r.Refspec)
 281-		if err == nil {
 282-			return Hash(id)
 283-		}
 284-	}
 285-	return r.Hash
 286-}
 287diff --git a/git/repo.go b/git/repo.go
 288index ef3d2759d63411b4d7f297ebb838eb959bd6b00d..b71abd1cffd4929c11bb755e59e286a0f7e08e96 100644
 289--- a/git/repo.go
 290+++ b/git/repo.go
 291@@ -77,7 +77,6 @@ func (r *Repository) HEAD() (*Reference, error) {
 292 			ID:      hash,
 293 			Refspec: rn,
 294 		},
 295-		Hash: Hash(hash),
 296 		path: r.Path,
 297 	}, nil
 298 }
 299@@ -92,7 +91,6 @@ func (r *Repository) References() ([]*Reference, error) {
 300 	for _, ref := range refs {
 301 		rrefs = append(rrefs, &Reference{
 302 			Reference: ref,
 303-			Hash:      Hash(ref.ID),
 304 			path:      r.Path,
 305 		})
 306 	}
 307@@ -121,7 +119,7 @@ func (r *Repository) Tree(ref *Reference) (*Tree, error) {
 308 		}
 309 		ref = rref
 310 	}
 311-	return r.LsTree(ref.Hash.String())
 312+	return r.LsTree(ref.ID)
 313 }
 314 
 315 // TreePath returns the tree for the given path.
 316@@ -142,7 +140,7 @@ func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
 317 
 318 // Diff returns the diff for the given commit.
 319 func (r *Repository) Diff(commit *Commit) (*Diff, error) {
 320-	ddiff, err := r.Repository.Diff(commit.Hash.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
 321+	diff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
 322 		CommandOptions: git.CommandOptions{
 323 			Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
 324 		},
 325@@ -150,24 +148,7 @@ func (r *Repository) Diff(commit *Commit) (*Diff, error) {
 326 	if err != nil {
 327 		return nil, err
 328 	}
 329-	files := make([]*DiffFile, 0, len(ddiff.Files))
 330-	for _, df := range ddiff.Files {
 331-		sections := make([]*DiffSection, 0, len(df.Sections))
 332-		for _, ds := range df.Sections {
 333-			sections = append(sections, &DiffSection{
 334-				DiffSection: ds,
 335-			})
 336-		}
 337-		files = append(files, &DiffFile{
 338-			DiffFile: df,
 339-			Sections: sections,
 340-		})
 341-	}
 342-	diff := &Diff{
 343-		Diff:  ddiff,
 344-		Files: files,
 345-	}
 346-	return diff, nil
 347+	return toDiff(diff), nil
 348 }
 349 
 350 // Patch returns the patch for the given reference.
 351@@ -191,12 +172,7 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
 352 		return nil, err
 353 	}
 354 	commits := make(Commits, len(cs))
 355-	for i, c := range cs {
 356-		commits[i] = &Commit{
 357-			Commit: c,
 358-			Hash:   Hash(c.ID.String()),
 359-		}
 360-	}
 361+	copy(commits, cs)
 362 	return commits, nil
 363 }
 364 
 365diff --git a/git/stash.go b/git/stash.go
 366new file mode 100644
 367index 0000000000000000000000000000000000000000..0a669ddd395c33f59cd6b66cf30dd8e9e4169fd1
 368--- /dev/null
 369+++ b/git/stash.go
 370@@ -0,0 +1,16 @@
 371+package git
 372+
 373+import "github.com/gogs/git-module"
 374+
 375+// StashDiff returns the diff of the given stash index.
 376+func (r *Repository) StashDiff(index int) (*Diff, error) {
 377+	diff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
 378+		CommandOptions: git.CommandOptions{
 379+			Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
 380+		},
 381+	})
 382+	if err != nil {
 383+		return nil, err
 384+	}
 385+	return toDiff(diff), nil
 386+}
 387diff --git a/git/tag.go b/git/tag.go
 388new file mode 100644
 389index 0000000000000000000000000000000000000000..f09d39a25a58c888d413734da5c53e5897254862
 390--- /dev/null
 391+++ b/git/tag.go
 392@@ -0,0 +1,6 @@
 393+package git
 394+
 395+import "github.com/gogs/git-module"
 396+
 397+// Tag is a git tag.
 398+type Tag = git.Tag
 399diff --git a/git/utils.go b/git/utils.go
 400index 3710e172de7772b6c28c2a7e2f6806343c940028..b4ca50fc62d987eb823dc65cff0572a500f057d6 100644
 401--- a/git/utils.go
 402+++ b/git/utils.go
 403@@ -8,14 +8,17 @@ import (
 404 )
 405 
 406 // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path.
 407-func LatestFile(repo *Repository, pattern string) (string, string, error) {
 408+func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) {
 409 	g := glob.MustCompile(pattern)
 410 	dir := filepath.Dir(pattern)
 411-	head, err := repo.HEAD()
 412-	if err != nil {
 413-		return "", "", err
 414+	if ref == nil {
 415+		head, err := repo.HEAD()
 416+		if err != nil {
 417+			return "", "", err
 418+		}
 419+		ref = head
 420 	}
 421-	t, err := repo.TreePath(head, dir)
 422+	t, err := repo.TreePath(ref, dir)
 423 	if err != nil {
 424 		return "", "", err
 425 	}
 426diff --git a/go.mod b/go.mod
 427index dfd99002a3b4f54eba8325d8acbbd056862e5788..25682ab2cf20e14a46498bc18a094d8557d4eab4 100644
 428--- a/go.mod
 429+++ b/go.mod
 430@@ -2,6 +2,8 @@ module github.com/charmbracelet/soft-serve
 431 
 432 go 1.20
 433 
 434+replace github.com/gogs/git-module => github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213
 435+
 436 require (
 437 	github.com/alecthomas/chroma v0.10.0
 438 	github.com/charmbracelet/bubbles v0.16.1
 439diff --git a/go.sum b/go.sum
 440index 37e2c33b441280bf6fef695e93c9e8f8f1b68e9f..22b77cacd99bb1c500becd06bad4eb4031a1941b 100644
 441--- a/go.sum
 442+++ b/go.sum
 443@@ -4,6 +4,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 444 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 445 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 446 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
 447+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213 h1:/tUfPeV5T/tn2UjvQedq1incFa9B9WkFHTv0fdt5Ah0=
 448+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213/go.mod h1:3OBxY2gWeblk83u6BlGMO1TYDEbV4bspATMP/S2Kfsk=
 449 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 450 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 451 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
 452@@ -63,8 +65,6 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
 453 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 454 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 455 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 456-github.com/gogs/git-module v1.8.3 h1:4N9HOLzkmSfb5y4Go4f/gdt1/Z60/aQaAKr8lbsfFps=
 457-github.com/gogs/git-module v1.8.3/go.mod h1:yAn6ZMwh8x0u3fMotXqMP7Ct1XNNOZWNdBSBx6IFGCY=
 458 github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
 459 github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 460 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 461@@ -209,7 +209,6 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS
 462 golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
 463 golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 464 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 465-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 466 golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
 467 golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 468 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 469diff --git a/server/backend/utils.go b/server/backend/utils.go
 470index 024ba8af228a6741d1e957580f90ae722c05584f..5e90873e5369615c3fb8eb62ef347995770bd9ae 100644
 471--- a/server/backend/utils.go
 472+++ b/server/backend/utils.go
 473@@ -7,17 +7,17 @@ import (
 474 
 475 // LatestFile returns the contents of the latest file at the specified path in
 476 // the repository and its file path.
 477-func LatestFile(r proto.Repository, pattern string) (string, string, error) {
 478+func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) {
 479 	repo, err := r.Open()
 480 	if err != nil {
 481 		return "", "", err
 482 	}
 483-	return git.LatestFile(repo, pattern)
 484+	return git.LatestFile(repo, ref, pattern)
 485 }
 486 
 487 // Readme returns the repository's README.
 488-func Readme(r proto.Repository) (readme string, path string, err error) {
 489+func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) {
 490 	pattern := "[rR][eE][aA][dD][mM][eE]*"
 491-	readme, path, err = LatestFile(r, pattern)
 492+	readme, path, err = LatestFile(r, ref, pattern)
 493 	return
 494 }
 495diff --git a/server/ssh/cmd/blob.go b/server/ssh/cmd/blob.go
 496index 5065bd1c63177cd86cf0c0e9d4af6515eded0f1a..06034d94a88b5840de084a80350368fe712ffdf0 100644
 497--- a/server/ssh/cmd/blob.go
 498+++ b/server/ssh/cmd/blob.go
 499@@ -2,29 +2,21 @@ package cmd
 500 
 501 import (
 502 	"fmt"
 503-	"strings"
 504 
 505-	"github.com/alecthomas/chroma/lexers"
 506-	gansi "github.com/charmbracelet/glamour/ansi"
 507-	"github.com/charmbracelet/lipgloss"
 508 	"github.com/charmbracelet/soft-serve/git"
 509 	"github.com/charmbracelet/soft-serve/server/backend"
 510 	"github.com/charmbracelet/soft-serve/server/ui/common"
 511-	"github.com/muesli/termenv"
 512+	"github.com/charmbracelet/soft-serve/server/ui/styles"
 513 	"github.com/spf13/cobra"
 514 )
 515 
 516-var (
 517-	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
 518-	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
 519-)
 520-
 521 // blobCommand returns a command that prints the contents of a file.
 522 func blobCommand() *cobra.Command {
 523 	var linenumber bool
 524 	var color bool
 525 	var raw bool
 526 
 527+	styles := styles.DefaultStyles()
 528 	cmd := &cobra.Command{
 529 		Use:               "blob REPOSITORY [REFERENCE] [PATH]",
 530 		Aliases:           []string{"cat", "show"},
 531@@ -60,7 +52,7 @@ func blobCommand() *cobra.Command {
 532 				if err != nil {
 533 					return err
 534 				}
 535-				ref = head.Hash.String()
 536+				ref = head.ID
 537 			}
 538 
 539 			tree, err := r.LsTree(ref)
 540@@ -92,14 +84,14 @@ func blobCommand() *cobra.Command {
 541 				}
 542 			} else {
 543 				if color {
 544-					c, err = withFormatting(fp, c)
 545+					c, err = common.FormatHighlight(fp, c)
 546 					if err != nil {
 547 						return err
 548 					}
 549 				}
 550 
 551 				if linenumber {
 552-					c = withLineNumber(c, color)
 553+					c, _ = common.FormatLineNumber(styles, c, color)
 554 				}
 555 
 556 				cmd.Println(c)
 557@@ -114,50 +106,3 @@ func blobCommand() *cobra.Command {
 558 
 559 	return cmd
 560 }
 561-
 562-func withLineNumber(s string, color bool) string {
 563-	lines := strings.Split(s, "\n")
 564-	// NB: len() is not a particularly safe way to count string width (because
 565-	// it's counting bytes instead of runes) but in this case it's okay
 566-	// because we're only dealing with digits, which are one byte each.
 567-	mll := len(fmt.Sprintf("%d", len(lines)))
 568-	for i, l := range lines {
 569-		digit := fmt.Sprintf("%*d", mll, i+1)
 570-		bar := "│"
 571-		if color {
 572-			digit = lineDigitStyle.Render(digit)
 573-			bar = lineBarStyle.Render(bar)
 574-		}
 575-		if i < len(lines)-1 || len(l) != 0 {
 576-			// If the final line was a newline we'll get an empty string for
 577-			// the final line, so drop the newline altogether.
 578-			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
 579-		}
 580-	}
 581-	return strings.Join(lines, "\n")
 582-}
 583-
 584-func withFormatting(p, c string) (string, error) {
 585-	zero := uint(0)
 586-	lang := ""
 587-	lexer := lexers.Match(p)
 588-	if lexer != nil && lexer.Config() != nil {
 589-		lang = lexer.Config().Name
 590-	}
 591-	formatter := &gansi.CodeBlockElement{
 592-		Code:     c,
 593-		Language: lang,
 594-	}
 595-	r := strings.Builder{}
 596-	styles := common.StyleConfig()
 597-	styles.CodeBlock.Margin = &zero
 598-	rctx := gansi.NewRenderContext(gansi.Options{
 599diff --git a/server/ssh/cmd/commit.go b/server/ssh/cmd/commit.go
 600index f8ffaa1cf18aaaf3fb663de66aeabeb656c52028..75f02efbded39de4a8ed4ab157d1d7ba00e732ec 100644
 601--- a/server/ssh/cmd/commit.go
 602+++ b/server/ssh/cmd/commit.go
 603@@ -40,16 +40,11 @@ func commitCommand() *cobra.Command {
 604 				return err
 605 			}
 606 
 607-			rawCommit, err := r.CommitByRevision(commitSHA)
 608+			commit, err := r.CommitByRevision(commitSHA)
 609 			if err != nil {
 610 				return err
 611 			}
 612 
 613-			commit := &git.Commit{
 614-				Commit: rawCommit,
 615-				Hash:   git.Hash(commitSHA),
 616-			}
 617-
 618 			patch, err := r.Patch(commit)
 619 			if err != nil {
 620 				return err
 621diff --git a/server/ssh/cmd/tree.go b/server/ssh/cmd/tree.go
 622index ccb72a70f890a6bd5800bbb1379b831839ef3ee1..e380a348ea7f6d1ed41914343f60399693896187 100644
 623--- a/server/ssh/cmd/tree.go
 624+++ b/server/ssh/cmd/tree.go
 625@@ -49,7 +49,7 @@ func treeCommand() *cobra.Command {
 626 					return err
 627 				}
 628 
 629-				ref = head.Hash.String()
 630+				ref = head.ID
 631 			}
 632 
 633 			tree, err := r.LsTree(ref)
 634diff --git a/server/ssh/session.go b/server/ssh/session.go
 635index a5bd4d168d7e71f0d6d4038ce173108cc0967c8f..53496e3af78c886343d5edfb07efff5a75dac86d 100644
 636--- a/server/ssh/session.go
 637+++ b/server/ssh/session.go
 638@@ -9,7 +9,6 @@ import (
 639 	"github.com/charmbracelet/soft-serve/server/backend"
 640 	"github.com/charmbracelet/soft-serve/server/config"
 641 	"github.com/charmbracelet/soft-serve/server/proto"
 642-	"github.com/charmbracelet/soft-serve/server/ui"
 643 	"github.com/charmbracelet/soft-serve/server/ui/common"
 644 	"github.com/charmbracelet/ssh"
 645 	"github.com/charmbracelet/wish"
 646@@ -58,7 +57,7 @@ func SessionHandler(s ssh.Session) *tea.Program {
 647 	output := termenv.NewOutput(s, termenv.WithColorCache(true), termenv.WithEnvironment(envs))
 648 	c := common.NewCommon(ctx, output, pty.Window.Width, pty.Window.Height)
 649 	c.SetValue(common.ConfigKey, cfg)
 650-	m := ui.New(c, initialRepo)
 651+	m := NewUI(c, initialRepo)
 652 	p := tea.NewProgram(m,
 653 		tea.WithInput(s),
 654 		tea.WithOutput(s),
 655diff --git a/server/ui/ui.go b/server/ssh/ui.go
 656rename from server/ui/ui.go
 657rename to server/ssh/ui.go
 658index 5e259004d7b91fada6151713c9c6c6adcb939a95..87c73ad711067dda2f06735bc88c3347628d00d6 100644
 659--- a/server/ui/ui.go
 660+++ b/server/ssh/ui.go
 661@@ -1,4 +1,4 @@
 662-package ui
 663+package ssh
 664 
 665 import (
 666 	"errors"
 667@@ -7,6 +7,7 @@ import (
 668 	"github.com/charmbracelet/bubbles/list"
 669 	tea "github.com/charmbracelet/bubbletea"
 670 	"github.com/charmbracelet/lipgloss"
 671+	"github.com/charmbracelet/soft-serve/git"
 672 	"github.com/charmbracelet/soft-serve/server/proto"
 673 	"github.com/charmbracelet/soft-serve/server/ui/common"
 674 	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
 675@@ -45,8 +46,8 @@ type UI struct {
 676 	error       error
 677 }
 678 
 679-// New returns a new UI model.
 680-func New(c common.Common, initialRepo string) *UI {
 681+// NewUI returns a new UI model.
 682+func NewUI(c common.Common, initialRepo string) *UI {
 683 	serverName := c.Config().Name
 684 	h := header.New(c, serverName)
 685 	ui := &UI{
 686@@ -133,7 +134,13 @@ func (ui *UI) SetSize(width, height int) {
 687 // Init implements tea.Model.
 688 func (ui *UI) Init() tea.Cmd {
 689 	ui.pages[selectionPage] = selection.New(ui.common)
 690-	ui.pages[repoPage] = repo.New(ui.common)
 691+	ui.pages[repoPage] = repo.New(ui.common,
 692+		repo.NewReadme(ui.common),
 693+		repo.NewFiles(ui.common),
 694+		repo.NewLog(ui.common),
 695+		repo.NewRefs(ui.common, git.RefsHeads),
 696+		repo.NewRefs(ui.common, git.RefsTags),
 697+	)
 698 	ui.SetSize(ui.common.Width, ui.common.Height)
 699 	cmds := make([]tea.Cmd, 0)
 700 	cmds = append(cmds,
 701@@ -273,10 +280,10 @@ func (ui *UI) View() string {
 702 		view = "Unknown state :/ this is a bug!"
 703 	}
 704 	if ui.activePage == selectionPage {
 705-		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
 706+		view = lipgloss.JoinVertical(lipgloss.Top, ui.header.View(), view)
 707 	}
 708 	if ui.showFooter {
 709-		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
 710+		view = lipgloss.JoinVertical(lipgloss.Top, view, ui.footer.View())
 711 	}
 712 	return ui.common.Zone.Scan(
 713 		ui.common.Styles.App.Render(view),
 714diff --git a/server/ui/common/component.go b/server/ui/common/component.go
 715index ed8b9bf05cd39b52086a2a490b045231ac1fd96e..1f4d20df875384b23e21cc6977fa8e40b4296e6c 100644
 716--- a/server/ui/common/component.go
 717+++ b/server/ui/common/component.go
 718@@ -11,3 +11,21 @@ type Component interface {
 719 	help.KeyMap
 720 	SetSize(width, height int)
 721 }
 722+
 723+// TabComponenet represents a model that is mounted to a tab.
 724+// TODO: find a better name
 725+type TabComponent interface {
 726+	Component
 727+
 728+	// StatusBarValue returns the status bar value component.
 729+	StatusBarValue() string
 730+
 731+	// StatusBarInfo returns the status bar info component.
 732+	StatusBarInfo() string
 733+
 734+	// SpinnerID returns the ID of the spinner.
 735+	SpinnerID() int
 736+
 737+	// TabName returns the name of the tab.
 738+	TabName() string
 739+}
 740diff --git a/server/ui/common/format.go b/server/ui/common/format.go
 741new file mode 100644
 742index 0000000000000000000000000000000000000000..90d00dc547d77d64a10d903fbd4fb86ed74ad95c
 743--- /dev/null
 744+++ b/server/ui/common/format.go
 745@@ -0,0 +1,60 @@
 746+package common
 747+
 748+import (
 749+	"fmt"
 750+	"strings"
 751+
 752+	"github.com/alecthomas/chroma/lexers"
 753+	gansi "github.com/charmbracelet/glamour/ansi"
 754+	"github.com/charmbracelet/soft-serve/server/ui/styles"
 755+	"github.com/muesli/termenv"
 756+)
 757+
 758+// FormatLineNumber adds line numbers to a string.
 759+func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) {
 760+	lines := strings.Split(s, "\n")
 761+	// NB: len() is not a particularly safe way to count string width (because
 762+	// it's counting bytes instead of runes) but in this case it's okay
 763+	// because we're only dealing with digits, which are one byte each.
 764+	mll := len(fmt.Sprintf("%d", len(lines)))
 765+	for i, l := range lines {
 766+		digit := fmt.Sprintf("%*d", mll, i+1)
 767+		bar := "│"
 768+		if color {
 769+			digit = styles.Code.LineDigit.Render(digit)
 770+			bar = styles.Code.LineBar.Render(bar)
 771+		}
 772+		if i < len(lines)-1 || len(l) != 0 {
 773+			// If the final line was a newline we'll get an empty string for
 774+			// the final line, so drop the newline altogether.
 775+			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
 776+		}
 777+	}
 778+	return strings.Join(lines, "\n"), mll
 779+}
 780+
 781+// FormatHighlight adds syntax highlighting to a string.
 782+func FormatHighlight(p, c string) (string, error) {
 783+	zero := uint(0)
 784+	lang := ""
 785+	lexer := lexers.Match(p)
 786+	if lexer != nil && lexer.Config() != nil {
 787+		lang = lexer.Config().Name
 788+	}
 789+	formatter := &gansi.CodeBlockElement{
 790+		Code:     c,
 791+		Language: lang,
 792+	}
 793+	r := strings.Builder{}
 794+	styles := StyleConfig()
 795+	styles.CodeBlock.Margin = &zero
 796+	rctx := gansi.NewRenderContext(gansi.Options{
 797+		Styles:       styles,
 798+		ColorProfile: termenv.TrueColor,
 799+	})
 800+	err := formatter.Render(&r, rctx)
 801+	if err != nil {
 802+		return "", err
 803+	}
 804+	return r.String(), nil
 805+}
 806diff --git a/server/ui/common/utils.go b/server/ui/common/utils.go
 807index aac2db6da3bd5f496f0c77409382323d4a0080fe..49eaf00ce2ff31f51ca0508c005605eb413aa0a2 100644
 808--- a/server/ui/common/utils.go
 809+++ b/server/ui/common/utils.go
 810@@ -35,6 +35,6 @@ func RepoURL(publicURL, name string) string {
 811 }
 812 
 813 // CloneCmd returns the URL of the repository.
 814-func CloneCmd(publicURL, name string) string {
 815+var CloneCmd = func(publicURL, name string) string {
 816 	return fmt.Sprintf("git clone %s", RepoURL(publicURL, name))
 817 }
 818diff --git a/server/ui/components/code/code.go b/server/ui/components/code/code.go
 819index aff1a526506d0e3eb37a40cf7164f9797f833d78..5d6d4a91ea6ddb4c9fbd321bc0a4cdd91aa5854d 100644
 820--- a/server/ui/components/code/code.go
 821+++ b/server/ui/components/code/code.go
 822@@ -1,7 +1,7 @@
 823 package code
 824 
 825 import (
 826-	"fmt"
 827+	"math"
 828 	"strings"
 829 	"sync"
 830 
 831@@ -16,40 +16,38 @@ import (
 832 )
 833 
 834 const (
 835-	tabWidth = 4
 836-)
 837-
 838-var (
 839-	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
 840-	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
 841+	defaultTabWidth        = 4
 842+	defaultSideNotePercent = 0.3
 843 )
 844 
 845 // Code is a code snippet.
 846 type Code struct {
 847 	*vp.Viewport
 848-	common         common.Common
 849-	content        string
 850-	extension      string
 851-	renderContext  gansi.RenderContext
 852-	renderMutex    sync.Mutex
 853-	styleConfig    gansi.StyleConfig
 854-	showLineNumber bool
 855-
 856-	NoContentStyle lipgloss.Style
 857-	LineDigitStyle lipgloss.Style
 858-	LineBarStyle   lipgloss.Style
 859+	common        common.Common
 860+	sidenote      string
 861+	content       string
 862+	extension     string
 863+	renderContext gansi.RenderContext
 864+	renderMutex   sync.Mutex
 865+	styleConfig   gansi.StyleConfig
 866+
 867+	SideNotePercent float64
 868+	TabWidth        int
 869+	ShowLineNumber  bool
 870+	NoContentStyle  lipgloss.Style
 871+	UseGlamour      bool
 872 }
 873 
 874 // New returns a new Code.
 875 func New(c common.Common, content, extension string) *Code {
 876 	r := &Code{
 877-		common:         c,
 878-		content:        content,
 879-		extension:      extension,
 880-		Viewport:       vp.New(c),
 881-		NoContentStyle: c.Styles.NoContent.Copy(),
 882-		LineDigitStyle: lineDigitStyle,
 883-		LineBarStyle:   lineBarStyle,
 884+		common:          c,
 885+		content:         content,
 886+		extension:       extension,
 887+		TabWidth:        defaultTabWidth,
 888+		SideNotePercent: defaultSideNotePercent,
 889+		Viewport:        vp.New(c),
 890+		NoContentStyle:  c.Styles.NoContent.Copy().SetString("No Content."),
 891 	}
 892 	st := common.StyleConfig()
 893 	r.styleConfig = st
 894@@ -61,11 +59,6 @@ func New(c common.Common, content, extension string) *Code {
 895 	return r
 896 }
 897 
 898-// SetShowLineNumber sets whether to show line numbers.
 899-func (r *Code) SetShowLineNumber(show bool) {
 900-	r.showLineNumber = show
 901-}
 902-
 903 // SetSize implements common.Component.
 904 func (r *Code) SetSize(width, height int) {
 905 	r.common.SetSize(width, height)
 906@@ -79,19 +72,62 @@ func (r *Code) SetContent(c, ext string) tea.Cmd {
 907 	return r.Init()
 908 }
 909 
 910+// SetSideNote sets the sidenote of the Code.
 911+func (r *Code) SetSideNote(s string) tea.Cmd {
 912+	r.sidenote = s
 913+	return r.Init()
 914+}
 915+
 916 // Init implements tea.Model.
 917 func (r *Code) Init() tea.Cmd {
 918 	w := r.common.Width
 919-	c := r.content
 920-	if c == "" {
 921+	content := r.content
 922diff --git a/server/ui/components/selector/selector.go b/server/ui/components/selector/selector.go
 923index aaf3191f3ba52e77035c2c165caa6fdefc30245a..22dcfefaf3ff4210063fbb93aaa5b99be31d3265 100644
 924--- a/server/ui/components/selector/selector.go
 925+++ b/server/ui/components/selector/selector.go
 926@@ -1,6 +1,8 @@
 927 package selector
 928 
 929 import (
 930+	"sync"
 931+
 932 	"github.com/charmbracelet/bubbles/key"
 933 	"github.com/charmbracelet/bubbles/list"
 934 	tea "github.com/charmbracelet/bubbletea"
 935@@ -9,10 +11,16 @@ import (
 936 
 937 // Selector is a list of items that can be selected.
 938 type Selector struct {
 939-	list.Model
 940+	*list.Model
 941 	common      common.Common
 942 	active      int
 943 	filterState list.FilterState
 944+
 945+	// XXX: we use a mutex to support concurrent access to the model. This is
 946+	// needed to implement pagination for the Log component. list.Model does
 947+	// not support item pagination so we hack it ourselves on top of
 948+	// list.Model.
 949+	mtx sync.RWMutex
 950 }
 951 
 952 // IdentifiableItem is an item that can be identified by a string. Implements
 953@@ -40,9 +48,9 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
 954 		itms[i] = item
 955 	}
 956 	l := list.New(itms, delegate, common.Width, common.Height)
 957-	l.Styles.NoItems = common.Styles.NoItems
 958+	l.Styles.NoItems = common.Styles.NoContent
 959 	s := &Selector{
 960-		Model:  l,
 961+		Model:  &l,
 962 		common: common,
 963 	}
 964 	s.SetSize(common.Width, common.Height)
 965@@ -51,66 +59,111 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
 966 
 967 // PerPage returns the number of items per page.
 968 func (s *Selector) PerPage() int {
 969+	s.mtx.RLock()
 970+	defer s.mtx.RUnlock()
 971 	return s.Model.Paginator.PerPage
 972 }
 973 
 974 // SetPage sets the current page.
 975 func (s *Selector) SetPage(page int) {
 976+	s.mtx.Lock()
 977+	defer s.mtx.Unlock()
 978 	s.Model.Paginator.Page = page
 979 }
 980 
 981 // Page returns the current page.
 982 func (s *Selector) Page() int {
 983+	s.mtx.RLock()
 984+	defer s.mtx.RUnlock()
 985 	return s.Model.Paginator.Page
 986 }
 987 
 988 // TotalPages returns the total number of pages.
 989 func (s *Selector) TotalPages() int {
 990+	s.mtx.RLock()
 991+	defer s.mtx.RUnlock()
 992 	return s.Model.Paginator.TotalPages
 993 }
 994 
 995+// SetTotalPages sets the total number of pages given the number of items.
 996+func (s *Selector) SetTotalPages(items int) int {
 997+	s.mtx.Lock()
 998+	defer s.mtx.Unlock()
 999+	return s.Model.Paginator.SetTotalPages(items)
1000+}
1001+
1002+// SelectedItem returns the currently selected item.
1003+func (s *Selector) SelectedItem() IdentifiableItem {
1004+	s.mtx.RLock()
1005+	defer s.mtx.RUnlock()
1006+	item := s.Model.SelectedItem()
1007+	i, ok := item.(IdentifiableItem)
1008+	if !ok {
1009+		return nil
1010+	}
1011+	return i
1012+}
1013+
1014 // Select selects the item at the given index.
1015 func (s *Selector) Select(index int) {
1016+	s.mtx.RLock()
1017+	defer s.mtx.RUnlock()
1018 	s.Model.Select(index)
1019 }
1020 
1021 // SetShowTitle sets the show title flag.
1022 func (s *Selector) SetShowTitle(show bool) {
1023+	s.mtx.Lock()
1024+	defer s.mtx.Unlock()
1025 	s.Model.SetShowTitle(show)
1026diff --git a/server/ui/components/statusbar/statusbar.go b/server/ui/components/statusbar/statusbar.go
1027index e5f0f70a5d7f35bebfebdc23d13003570e02aca8..f60e07795b4dfd1d7b053484be6e4514ae0562eb 100644
1028--- a/server/ui/components/statusbar/statusbar.go
1029+++ b/server/ui/components/statusbar/statusbar.go
1030@@ -7,16 +7,8 @@ import (
1031 	"github.com/muesli/reflow/truncate"
1032 )
1033 
1034-// StatusBarMsg is a message sent to the status bar.
1035-type StatusBarMsg struct { //nolint:revive
1036-	Key   string
1037-	Value string
1038-	Info  string
1039-	Extra string
1040-}
1041-
1042-// StatusBar is a status bar model.
1043-type StatusBar struct {
1044+// Model is a status bar model.
1045+type Model struct {
1046 	common common.Common
1047 	key    string
1048 	value  string
1049@@ -24,53 +16,52 @@ type StatusBar struct {
1050 	extra  string
1051 }
1052 
1053-// Model is an interface that supports setting the status bar information.
1054-type Model interface {
1055-	StatusBarValue() string
1056-	StatusBarInfo() string
1057-}
1058-
1059 // New creates a new status bar component.
1060-func New(c common.Common) *StatusBar {
1061-	s := &StatusBar{
1062+func New(c common.Common) *Model {
1063+	s := &Model{
1064 		common: c,
1065 	}
1066 	return s
1067 }
1068 
1069 // SetSize implements common.Component.
1070-func (s *StatusBar) SetSize(width, height int) {
1071+func (s *Model) SetSize(width, height int) {
1072 	s.common.Width = width
1073 	s.common.Height = height
1074 }
1075 
1076+// SetStatus sets the status bar status.
1077+func (s *Model) SetStatus(key, value, info, extra string) {
1078+	if key != "" {
1079+		s.key = key
1080+	}
1081+	if value != "" {
1082+		s.value = value
1083+	}
1084+	if info != "" {
1085+		s.info = info
1086+	}
1087+	if extra != "" {
1088+		s.extra = extra
1089+	}
1090+}
1091+
1092 // Init implements tea.Model.
1093-func (s *StatusBar) Init() tea.Cmd {
1094+func (s *Model) Init() tea.Cmd {
1095 	return nil
1096 }
1097 
1098 // Update implements tea.Model.
1099-func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1100+func (s *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1101 	switch msg := msg.(type) {
1102-	case StatusBarMsg:
1103-		if msg.Key != "" {
1104-			s.key = msg.Key
1105-		}
1106-		if msg.Value != "" {
1107-			s.value = msg.Value
1108-		}
1109-		if msg.Info != "" {
1110-			s.info = msg.Info
1111-		}
1112-		if msg.Extra != "" {
1113-			s.extra = msg.Extra
1114-		}
1115+	case tea.WindowSizeMsg:
1116+		s.SetSize(msg.Width, msg.Height)
1117 	}
1118 	return s, nil
1119 }
1120 
1121 // View implements tea.Model.
1122-func (s *StatusBar) View() string {
1123+func (s *Model) View() string {
1124 	st := s.common.Styles
1125 	w := lipgloss.Width
1126 	help := s.common.Zone.Mark(
1127diff --git a/server/ui/pages/repo/files.go b/server/ui/pages/repo/files.go
1128index a0a79352bf4fee2b564be5cb1d6b10d810c6489e..38521a7be511b43d6d10f50fdb64e4fdc3503ec9 100644
1129--- a/server/ui/pages/repo/files.go
1130+++ b/server/ui/pages/repo/files.go
1131@@ -3,23 +3,26 @@ package repo
1132 import (
1133 	"errors"
1134 	"fmt"
1135-	"log"
1136 	"path/filepath"
1137+	"strings"
1138 
1139 	"github.com/alecthomas/chroma/lexers"
1140 	"github.com/charmbracelet/bubbles/key"
1141+	"github.com/charmbracelet/bubbles/spinner"
1142 	tea "github.com/charmbracelet/bubbletea"
1143 	"github.com/charmbracelet/soft-serve/git"
1144 	"github.com/charmbracelet/soft-serve/server/proto"
1145 	"github.com/charmbracelet/soft-serve/server/ui/common"
1146 	"github.com/charmbracelet/soft-serve/server/ui/components/code"
1147 	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
1148+	gitm "github.com/gogs/git-module"
1149 )
1150 
1151 type filesView int
1152 
1153 const (
1154-	filesViewFiles filesView = iota
1155+	filesViewLoading filesView = iota
1156+	filesViewFiles
1157 	filesViewContent
1158 )
1159 
1160@@ -34,6 +37,14 @@ var (
1161 		key.WithKeys("l"),
1162 		key.WithHelp("l", "toggle line numbers"),
1163 	)
1164+	blameView = key.NewBinding(
1165+		key.WithKeys("b"),
1166+		key.WithHelp("b", "toggle blame view"),
1167+	)
1168+	preview = key.NewBinding(
1169+		key.WithKeys("p"),
1170+		key.WithHelp("p", "toggle preview"),
1171+	)
1172 )
1173 
1174 // FileItemsMsg is a message that contains a list of files.
1175@@ -45,6 +56,9 @@ type FileContentMsg struct {
1176 	ext     string
1177 }
1178 
1179+// FileBlameMsg is a message that contains the blame of a file.
1180+type FileBlameMsg *gitm.Blame
1181+
1182 // Files is the model for the files view.
1183 type Files struct {
1184 	common         common.Common
1185@@ -56,8 +70,12 @@ type Files struct {
1186 	path           string
1187 	currentItem    *FileItem
1188 	currentContent FileContentMsg
1189+	currentBlame   FileBlameMsg
1190 	lastSelected   []int
1191 	lineNumber     bool
1192+	spinner        spinner.Model
1193+	cursor         int
1194+	blameView      bool
1195 }
1196 
1197 // NewFiles creates a new files model.
1198@@ -65,7 +83,7 @@ func NewFiles(common common.Common) *Files {
1199 	f := &Files{
1200 		common:       common,
1201 		code:         code.New(common, "", ""),
1202-		activeView:   filesViewFiles,
1203+		activeView:   filesViewLoading,
1204 		lastSelected: make([]int, 0),
1205 		lineNumber:   true,
1206 	}
1207@@ -80,10 +98,18 @@ func NewFiles(common common.Common) *Files {
1208 	selector.KeyMap.NextPage = common.KeyMap.NextPage
1209 	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
1210 	f.selector = selector
1211-	f.code.SetShowLineNumber(f.lineNumber)
1212+	f.code.ShowLineNumber = f.lineNumber
1213+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
1214+		spinner.WithStyle(common.Styles.Spinner))
1215+	f.spinner = s
1216 	return f
1217 }
1218 
1219+// TabName returns the tab name.
1220+func (f *Files) TabName() string {
1221+	return "Files"
1222+}
1223+
1224 // SetSize implements common.Component.
1225 func (f *Files) SetSize(width, height int) {
1226 	f.common.SetSize(width, height)
1227@@ -96,30 +122,16 @@ func (f *Files) ShortHelp() []key.Binding {
1228 	k := f.selector.KeyMap
1229 	switch f.activeView {
1230 	case filesViewFiles:
1231diff --git a/server/ui/pages/repo/filesitem.go b/server/ui/pages/repo/filesitem.go
1232index d9d4841c23a376e5ebd2cb376e014d80d192bad5..c4f358b50cee559a8c480b6084fecaac8dc185b7 100644
1233--- a/server/ui/pages/repo/filesitem.go
1234+++ b/server/ui/pages/repo/filesitem.go
1235@@ -60,9 +60,8 @@ func (cl FileItems) Less(i, j int) bool {
1236 		return true
1237 	} else if cl[j].entry.IsTree() {
1238 		return false
1239-	} else {
1240-		return cl[i].Title() < cl[j].Title()
1241 	}
1242+	return cl[i].Title() < cl[j].Title()
1243 }
1244 
1245 // FileItemDelegate is the delegate for the file item list.
1246diff --git a/server/ui/pages/repo/log.go b/server/ui/pages/repo/log.go
1247index 60659e5a670e93489ec6965b7a5f7bc06faf4a7d..5ea5854316df744360f3c0ddd77fb6dd19f365ae 100644
1248--- a/server/ui/pages/repo/log.go
1249+++ b/server/ui/pages/repo/log.go
1250@@ -16,6 +16,7 @@ import (
1251 	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
1252 	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
1253 	"github.com/charmbracelet/soft-serve/server/ui/components/viewport"
1254+	"github.com/charmbracelet/soft-serve/server/ui/styles"
1255 	"github.com/muesli/reflow/wrap"
1256 	"github.com/muesli/termenv"
1257 )
1258@@ -25,7 +26,8 @@ var waitBeforeLoading = time.Millisecond * 100
1259 type logView int
1260 
1261 const (
1262-	logViewCommits logView = iota
1263+	logViewLoading logView = iota
1264+	logViewCommits
1265 	logViewDiff
1266 )
1267 
1268@@ -55,7 +57,6 @@ type Log struct {
1269 	selectedCommit *git.Commit
1270 	currentDiff    *git.Diff
1271 	loadingTime    time.Time
1272-	loading        bool
1273 	spinner        spinner.Model
1274 }
1275 
1276@@ -83,6 +84,11 @@ func NewLog(common common.Common) *Log {
1277 	return l
1278 }
1279 
1280+// TabName returns the name of the tab.
1281+func (l *Log) TabName() string {
1282+	return "Commits"
1283+}
1284+
1285 // SetSize implements common.Component.
1286 func (l *Log) SetSize(width, height int) {
1287 	l.common.SetSize(width, height)
1288@@ -163,15 +169,10 @@ func (l *Log) FullHelp() [][]key.Binding {
1289 
1290 func (l *Log) startLoading() tea.Cmd {
1291 	l.loadingTime = time.Now()
1292-	l.loading = true
1293+	l.activeView = logViewLoading
1294 	return l.spinner.Tick
1295 }
1296 
1297-func (l *Log) stopLoading() tea.Cmd {
1298-	l.loading = false
1299-	return updateStatusBarCmd
1300-}
1301-
1302 // Init implements tea.Model.
1303 func (l *Log) Init() tea.Cmd {
1304 	l.activeView = logViewCommits
1305@@ -179,9 +180,8 @@ func (l *Log) Init() tea.Cmd {
1306 	l.count = 0
1307 	l.activeCommit = nil
1308 	l.selectedCommit = nil
1309-	l.selector.Select(0)
1310 	return tea.Batch(
1311-		l.updateCommitsCmd,
1312+		l.countCommitsCmd,
1313 		// start loading on init
1314 		l.startLoading(),
1315 	)
1316@@ -195,15 +195,17 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1317 		l.repo = msg
1318 	case RefMsg:
1319 		l.ref = msg
1320+		l.selector.Select(0)
1321 		cmds = append(cmds, l.Init())
1322 	case LogCountMsg:
1323 		l.count = int64(msg)
1324+		l.selector.SetTotalPages(int(msg))
1325+		l.selector.SetItems(make([]selector.IdentifiableItem, l.count))
1326+		cmds = append(cmds, l.updateCommitsCmd)
1327 	case LogItemsMsg:
1328-		cmds = append(cmds,
1329-			l.selector.SetItems(msg),
1330-			// stop loading after receiving items
1331-			l.stopLoading(),
1332-		)
1333+		// stop loading after receiving items
1334+		l.activeView = logViewCommits
1335+		cmds = append(cmds, l.selector.SetItems(msg))
1336 		l.selector.SetPage(l.nextPage)
1337 		l.SetSize(l.common.Width, l.common.Height)
1338 		i := l.selector.SelectedItem()
1339@@ -217,10 +219,11 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1340 			case tea.KeyMsg:
1341 				switch {
1342 				case key.Matches(kmsg, l.common.KeyMap.SelectItem):
1343-					cmds = append(cmds, l.selector.SelectItem)
1344+					cmds = append(cmds, l.selector.SelectItemCmd)
1345 				}
1346 			}
1347-			// This is a hack for loading commits on demand based on list.Pagination.
1348+			// XXX: This is a hack for loading commits on demand based on
1349+			// list.Pagination.
1350diff --git a/server/ui/pages/repo/readme.go b/server/ui/pages/repo/readme.go
1351index 6c6b610f7c85f290fc0f2657330ff55a1aa8880f..fe7207326c81967fddbebf2a39d6791bb4a88a18 100644
1352--- a/server/ui/pages/repo/readme.go
1353+++ b/server/ui/pages/repo/readme.go
1354@@ -5,6 +5,7 @@ import (
1355 	"path/filepath"
1356 
1357 	"github.com/charmbracelet/bubbles/key"
1358+	"github.com/charmbracelet/bubbles/spinner"
1359 	tea "github.com/charmbracelet/bubbletea"
1360 	"github.com/charmbracelet/soft-serve/server/backend"
1361 	"github.com/charmbracelet/soft-serve/server/proto"
1362@@ -14,7 +15,8 @@ import (
1363 
1364 // ReadmeMsg is a message sent when the readme is loaded.
1365 type ReadmeMsg struct {
1366-	Msg tea.Msg
1367+	Content string
1368+	Path    string
1369 }
1370 
1371 // Readme is the readme component page.
1372@@ -24,18 +26,30 @@ type Readme struct {
1373 	ref        RefMsg
1374 	repo       proto.Repository
1375 	readmePath string
1376+	spinner    spinner.Model
1377+	isLoading  bool
1378 }
1379 
1380 // NewReadme creates a new readme model.
1381 func NewReadme(common common.Common) *Readme {
1382 	readme := code.New(common, "", "")
1383 	readme.NoContentStyle = readme.NoContentStyle.Copy().SetString("No readme found.")
1384+	readme.UseGlamour = true
1385+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
1386+		spinner.WithStyle(common.Styles.Spinner))
1387 	return &Readme{
1388-		code:   readme,
1389-		common: common,
1390+		code:      readme,
1391+		common:    common,
1392+		spinner:   s,
1393+		isLoading: true,
1394 	}
1395 }
1396 
1397+// TabName returns the name of the tab.
1398+func (r *Readme) TabName() string {
1399+	return "Readme"
1400+}
1401+
1402 // SetSize implements common.Component.
1403 func (r *Readme) SetSize(width, height int) {
1404 	r.common.SetSize(width, height)
1405@@ -72,7 +86,8 @@ func (r *Readme) FullHelp() [][]key.Binding {
1406 
1407 // Init implements tea.Model.
1408 func (r *Readme) Init() tea.Cmd {
1409-	return r.updateReadmeCmd
1410+	r.isLoading = true
1411+	return tea.Batch(r.spinner.Tick, r.updateReadmeCmd)
1412 }
1413 
1414 // Update implements tea.Model.
1415@@ -84,9 +99,26 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1416 	case RefMsg:
1417 		r.ref = msg
1418 		cmds = append(cmds, r.Init())
1419+	case tea.WindowSizeMsg:
1420+		r.SetSize(msg.Width, msg.Height)
1421 	case EmptyRepoMsg:
1422-		r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
1423-			r.repo.Name()), ".md")
1424+		cmds = append(cmds,
1425+			r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
1426+				r.repo.Name()), ".md"),
1427+		)
1428+	case ReadmeMsg:
1429+		r.isLoading = false
1430+		r.readmePath = msg.Path
1431+		r.code.GotoTop()
1432+		cmds = append(cmds, r.code.SetContent(msg.Content, msg.Path))
1433+	case spinner.TickMsg:
1434+		if r.isLoading && r.spinner.ID() == msg.ID {
1435+			s, cmd := r.spinner.Update(msg)
1436+			r.spinner = s
1437+			if cmd != nil {
1438+				cmds = append(cmds, cmd)
1439+			}
1440+		}
1441 	}
1442 	c, cmd := r.code.Update(msg)
1443 	r.code = c.(*code.Code)
1444@@ -98,34 +130,38 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1445 
1446 // View implements tea.Model.
1447 func (r *Readme) View() string {
1448+	if r.isLoading {
1449+		return renderLoading(r.common, r.spinner)
1450+	}
1451 	return r.code.View()
1452 }
1453 
1454diff --git a/server/ui/pages/repo/refs.go b/server/ui/pages/repo/refs.go
1455index 2dac5b38192d5c08c03d4ccc77fcbd4800f6cb69..e89eee409ec10706a7697157f1624b3c56372c10 100644
1456--- a/server/ui/pages/repo/refs.go
1457+++ b/server/ui/pages/repo/refs.go
1458@@ -6,17 +6,16 @@ import (
1459 	"strings"
1460 
1461 	"github.com/charmbracelet/bubbles/key"
1462+	"github.com/charmbracelet/bubbles/spinner"
1463 	tea "github.com/charmbracelet/bubbletea"
1464 	"github.com/charmbracelet/soft-serve/git"
1465-	ggit "github.com/charmbracelet/soft-serve/git"
1466 	"github.com/charmbracelet/soft-serve/server/proto"
1467 	"github.com/charmbracelet/soft-serve/server/ui/common"
1468 	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
1469-	"github.com/charmbracelet/soft-serve/server/ui/components/tabs"
1470 )
1471 
1472 // RefMsg is a message that contains a git.Reference.
1473-type RefMsg *ggit.Reference
1474+type RefMsg *git.Reference
1475 
1476 // RefItemsMsg is a message that contains a list of RefItem.
1477 type RefItemsMsg struct {
1478@@ -32,6 +31,8 @@ type Refs struct {
1479 	ref       *git.Reference
1480 	activeRef *git.Reference
1481 	refPrefix string
1482+	spinner   spinner.Model
1483+	isLoading bool
1484 }
1485 
1486 // NewRefs creates a new Refs component.
1487@@ -39,6 +40,7 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
1488 	r := &Refs{
1489 		common:    common,
1490 		refPrefix: refPrefix,
1491+		isLoading: true,
1492 	}
1493 	s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})
1494 	s.SetShowFilter(false)
1495@@ -49,9 +51,22 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
1496 	s.SetFilteringEnabled(false)
1497 	s.DisableQuitKeybindings()
1498 	r.selector = s
1499+	sp := spinner.New(spinner.WithSpinner(spinner.Dot),
1500+		spinner.WithStyle(common.Styles.Spinner))
1501+	r.spinner = sp
1502 	return r
1503 }
1504 
1505+// TabName returns the name of the tab.
1506+func (r *Refs) TabName() string {
1507+	if r.refPrefix == git.RefsHeads {
1508+		return "Branches"
1509+	} else if r.refPrefix == git.RefsTags {
1510+		return "Tags"
1511+	}
1512+	return "Refs"
1513+}
1514+
1515 // SetSize implements common.Component.
1516 func (r *Refs) SetSize(width, height int) {
1517 	r.common.SetSize(width, height)
1518@@ -94,7 +109,8 @@ func (r *Refs) FullHelp() [][]key.Binding {
1519 
1520 // Init implements tea.Model.
1521 func (r *Refs) Init() tea.Cmd {
1522-	return r.updateItemsCmd
1523+	r.isLoading = true
1524+	return tea.Batch(r.spinner.Tick, r.updateItemsCmd)
1525 }
1526 
1527 // Update implements tea.Model.
1528@@ -107,6 +123,8 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1529 	case RefMsg:
1530 		r.ref = msg
1531 		cmds = append(cmds, r.Init())
1532+	case tea.WindowSizeMsg:
1533+		r.SetSize(msg.Width, msg.Height)
1534 	case RefItemsMsg:
1535 		if r.refPrefix == msg.prefix {
1536 			cmds = append(cmds, r.selector.SetItems(msg.items))
1537@@ -114,29 +132,37 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
1538 			if i != nil {
1539 				r.activeRef = i.(RefItem).Reference
1540 			}
1541+			r.isLoading = false
1542 		}
1543 	case selector.ActiveMsg:
1544 		switch sel := msg.IdentifiableItem.(type) {
1545 		case RefItem:
1546 			r.activeRef = sel.Reference
1547 		}
1548-		cmds = append(cmds, updateStatusBarCmd)
1549 	case selector.SelectMsg:
1550 		switch i := msg.IdentifiableItem.(type) {
1551 		case RefItem:
1552 			cmds = append(cmds,
1553 				switchRefCmd(i.Reference),
1554-				tabs.SelectTabCmd(int(filesTab)),
1555+				switchTabCmd(&Files{}),
1556 			)
1557 		}
1558diff --git a/server/ui/pages/repo/refsitem.go b/server/ui/pages/repo/refsitem.go
1559index 0d552bf01c6a3fcbff257a56950634c86232cfea..4ebec866989cf15ae9e66a0e6cb7354ae45cd8ef 100644
1560--- a/server/ui/pages/repo/refsitem.go
1561+++ b/server/ui/pages/repo/refsitem.go
1562@@ -3,6 +3,8 @@ package repo
1563 import (
1564 	"fmt"
1565 	"io"
1566+	"strings"
1567+	"time"
1568 
1569 	"github.com/charmbracelet/bubbles/key"
1570 	"github.com/charmbracelet/bubbles/list"
1571@@ -10,11 +12,15 @@ import (
1572 	"github.com/charmbracelet/lipgloss"
1573 	"github.com/charmbracelet/soft-serve/git"
1574 	"github.com/charmbracelet/soft-serve/server/ui/common"
1575+	"github.com/dustin/go-humanize"
1576+	"github.com/muesli/reflow/truncate"
1577 )
1578 
1579 // RefItem is a git reference item.
1580 type RefItem struct {
1581 	*git.Reference
1582+	*git.Tag
1583+	*git.Commit
1584 }
1585 
1586 // ID implements selector.IdentifiableItem.
1587@@ -51,7 +57,12 @@ func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1588 
1589 // Less implements sort.Interface.
1590 func (cl RefItems) Less(i, j int) bool {
1591-	return cl[i].Short() < cl[j].Short()
1592+	if cl[i].Commit != nil && cl[j].Commit != nil {
1593+		return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)
1594+	} else if cl[i].Commit != nil && cl[j].Commit == nil {
1595+		return true
1596+	}
1597+	return false
1598 }
1599 
1600 // RefItemDelegate is the delegate for the ref item.
1601@@ -83,46 +94,112 @@ func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
1602 
1603 // Render implements list.ItemDelegate.
1604 func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1605-	s := d.common.Styles.Ref
1606 	i, ok := listItem.(RefItem)
1607 	if !ok {
1608 		return
1609 	}
1610 
1611-	var st lipgloss.Style
1612-	var selector string
1613-
1614 	isTag := i.Reference.IsTag()
1615 	isActive := index == m.Index()
1616+	s := d.common.Styles.Ref
1617+	st := s.Normal
1618+	selector := "  "
1619+	if isActive {
1620+		st = s.Active
1621+		selector = s.ItemSelector.String()
1622+	}
1623 
1624+	horizontalFrameSize := st.Base.GetHorizontalFrameSize()
1625+	var itemSt lipgloss.Style
1626 	if isTag && isActive {
1627-		st = s.Active.ItemTag
1628+		itemSt = st.ItemTag
1629 	} else if isTag {
1630-		st = s.Normal.ItemTag
1631+		itemSt = st.ItemTag
1632 	} else if isActive {
1633-		st = s.Active.Item
1634+		itemSt = st.Item
1635 	} else {
1636-		st = s.Normal.Item
1637+		itemSt = st.Item
1638 	}
1639 
1640-	if isActive {
1641-		selector = s.ItemSelector.String()
1642-	} else {
1643-		selector = "  "
1644+	var sha string
1645+	c := i.Commit
1646+	if c != nil {
1647+		sha = c.ID.String()[:7]
1648 	}
1649 
1650 	ref := i.Short()
1651-	ref = s.ItemBranch.Render(ref)
1652-	refMaxWidth := m.Width() -
1653-		s.ItemSelector.GetMarginLeft() -
1654-		s.ItemSelector.GetWidth() -
1655-		s.Normal.Item.GetMarginLeft()
1656-	ref = common.TruncateString(ref, refMaxWidth)
1657-	ref = st.Render(ref)
1658+
1659+	var desc string
1660+	if isTag {
1661+		if c != nil {
1662diff --git a/server/ui/pages/repo/repo.go b/server/ui/pages/repo/repo.go
1663index 3f1927ba8de10535f6712756c01566040eac5950..dde1f2fee6d5bc16aff4a75e409ee679ccffd3f6 100644
1664--- a/server/ui/pages/repo/repo.go
1665+++ b/server/ui/pages/repo/repo.go
1666@@ -2,6 +2,7 @@ package repo
1667 
1668 import (
1669 	"fmt"
1670+	"strings"
1671 
1672 	"github.com/charmbracelet/bubbles/help"
1673 	"github.com/charmbracelet/bubbles/key"
1674@@ -12,6 +13,7 @@ import (
1675 	"github.com/charmbracelet/soft-serve/server/proto"
1676 	"github.com/charmbracelet/soft-serve/server/ui/common"
1677 	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
1678+	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
1679 	"github.com/charmbracelet/soft-serve/server/ui/components/statusbar"
1680 	"github.com/charmbracelet/soft-serve/server/ui/components/tabs"
1681 )
1682@@ -23,41 +25,17 @@ const (
1683 	readyState
1684 )
1685 
1686-type tab int
1687-
1688-const (
1689-	readmeTab tab = iota
1690-	filesTab
1691-	commitsTab
1692-	branchesTab
1693-	tagsTab
1694-	lastTab
1695-)
1696-
1697-func (t tab) String() string {
1698-	return []string{
1699-		"Readme",
1700-		"Files",
1701-		"Commits",
1702-		"Branches",
1703-		"Tags",
1704-	}[t]
1705-}
1706-
1707 // EmptyRepoMsg is a message to indicate that the repository is empty.
1708 type EmptyRepoMsg struct{}
1709 
1710 // CopyURLMsg is a message to copy the URL of the current repository.
1711 type CopyURLMsg struct{}
1712 
1713-// UpdateStatusBarMsg updates the status bar.
1714-type UpdateStatusBarMsg struct{}
1715-
1716 // RepoMsg is a message that contains a git.Repository.
1717 type RepoMsg proto.Repository // nolint:revive
1718 
1719-// BackMsg is a message to go back to the previous view.
1720-type BackMsg struct{}
1721+// GoBackMsg is a message to go back to the previous view.
1722+type GoBackMsg struct{}
1723 
1724 // CopyMsg is a message to indicate copied text.
1725 type CopyMsg struct {
1726@@ -65,63 +43,60 @@ type CopyMsg struct {
1727 	Message string
1728 }
1729 
1730+// SwitchTabMsg is a message to switch tabs.
1731+type SwitchTabMsg common.TabComponent
1732+
1733 // Repo is a view for a git repository.
1734 type Repo struct {
1735 	common       common.Common
1736 	selectedRepo proto.Repository
1737-	activeTab    tab
1738+	activeTab    int
1739 	tabs         *tabs.Tabs
1740-	statusbar    *statusbar.StatusBar
1741-	panes        []common.Component
1742+	statusbar    *statusbar.Model
1743+	panes        []common.TabComponent
1744 	ref          *git.Reference
1745 	state        state
1746 	spinner      spinner.Model
1747-	panesReady   [lastTab]bool
1748+	panesReady   []bool
1749 }
1750 
1751 // New returns a new Repo.
1752-func New(c common.Common) *Repo {
1753+func New(c common.Common, comps ...common.TabComponent) *Repo {
1754 	sb := statusbar.New(c)
1755-	ts := make([]string, lastTab)
1756-	// Tabs must match the order of tab constants above.
1757-	for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
1758-		ts[i] = t.String()
1759+	ts := make([]string, 0)
1760+	for _, c := range comps {
1761+		ts = append(ts, c.TabName())
1762 	}
1763 	c.Logger = c.Logger.WithPrefix("ui.repo")
1764 	tb := tabs.New(c, ts)
1765-	readme := NewReadme(c)
1766diff --git a/server/ui/pages/repo/stash.go b/server/ui/pages/repo/stash.go
1767new file mode 100644
1768index 0000000000000000000000000000000000000000..0693743f39471d5419b1f4cd1732aa96e6556702
1769--- /dev/null
1770+++ b/server/ui/pages/repo/stash.go
1771@@ -0,0 +1,279 @@
1772+package repo
1773+
1774+import (
1775+	"fmt"
1776+
1777+	"github.com/charmbracelet/bubbles/key"
1778+	"github.com/charmbracelet/bubbles/spinner"
1779+	tea "github.com/charmbracelet/bubbletea"
1780+	"github.com/charmbracelet/lipgloss"
1781+	"github.com/charmbracelet/soft-serve/git"
1782+	"github.com/charmbracelet/soft-serve/server/proto"
1783+	"github.com/charmbracelet/soft-serve/server/ui/common"
1784+	"github.com/charmbracelet/soft-serve/server/ui/components/code"
1785+	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
1786+	gitm "github.com/gogs/git-module"
1787+)
1788+
1789+type stashState int
1790+
1791+const (
1792+	stashStateLoading stashState = iota
1793+	stashStateList
1794+	stashStatePatch
1795+)
1796+
1797+// StashListMsg is a message sent when the stash list is loaded.
1798+type StashListMsg []*gitm.Stash
1799+
1800+// StashPatchMsg is a message sent when the stash patch is loaded.
1801+type StashPatchMsg struct{ *git.Diff }
1802+
1803+// Stash is the stash component page.
1804+type Stash struct {
1805+	common       common.Common
1806+	code         *code.Code
1807+	ref          RefMsg
1808+	repo         proto.Repository
1809+	spinner      spinner.Model
1810+	list         *selector.Selector
1811+	state        stashState
1812+	currentPatch StashPatchMsg
1813+}
1814+
1815+// NewStash creates a new stash model.
1816+func NewStash(common common.Common) *Stash {
1817+	code := code.New(common, "", "")
1818+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
1819+		spinner.WithStyle(common.Styles.Spinner))
1820+	selector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common})
1821+	selector.SetShowFilter(false)
1822+	selector.SetShowHelp(false)
1823+	selector.SetShowPagination(false)
1824+	selector.SetShowStatusBar(false)
1825+	selector.SetShowTitle(false)
1826+	selector.SetFilteringEnabled(false)
1827+	selector.DisableQuitKeybindings()
1828+	selector.KeyMap.NextPage = common.KeyMap.NextPage
1829+	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
1830+	return &Stash{
1831+		code:    code,
1832+		common:  common,
1833+		spinner: s,
1834+		list:    selector,
1835+	}
1836+}
1837+
1838+// TabName returns the name of the tab.
1839+func (s *Stash) TabName() string {
1840+	return "Stash"
1841+}
1842+
1843+// SetSize implements common.Component.
1844+func (s *Stash) SetSize(width, height int) {
1845+	s.common.SetSize(width, height)
1846+	s.code.SetSize(width, height)
1847+	s.list.SetSize(width, height)
1848+}
1849+
1850+// ShortHelp implements help.KeyMap.
1851+func (s *Stash) ShortHelp() []key.Binding {
1852+	return []key.Binding{
1853+		s.common.KeyMap.Select,
1854+		s.common.KeyMap.Back,
1855+		s.common.KeyMap.UpDown,
1856+	}
1857+}
1858+
1859+// FullHelp implements help.KeyMap.
1860+func (s *Stash) FullHelp() [][]key.Binding {
1861+	b := [][]key.Binding{
1862+		{
1863+			s.common.KeyMap.Select,
1864+			s.common.KeyMap.Back,
1865+			s.common.KeyMap.Copy,
1866+		},
1867+		{
1868+			s.code.KeyMap.Down,
1869+			s.code.KeyMap.Up,
1870+			s.common.KeyMap.GotoTop,
1871diff --git a/server/ui/pages/repo/stashitem.go b/server/ui/pages/repo/stashitem.go
1872new file mode 100644
1873index 0000000000000000000000000000000000000000..482a40ad352b57f9c548bbcb84ba828e755fa16a
1874--- /dev/null
1875+++ b/server/ui/pages/repo/stashitem.go
1876@@ -0,0 +1,106 @@
1877+package repo
1878+
1879+import (
1880+	"fmt"
1881+	"io"
1882+
1883+	"github.com/charmbracelet/bubbles/key"
1884+	"github.com/charmbracelet/bubbles/list"
1885+	tea "github.com/charmbracelet/bubbletea"
1886+	"github.com/charmbracelet/soft-serve/server/ui/common"
1887+	gitm "github.com/gogs/git-module"
1888+)
1889+
1890+// StashItem represents a stash item.
1891+type StashItem struct{ *gitm.Stash }
1892+
1893+// ID returns the ID of the stash item.
1894+func (i StashItem) ID() string {
1895+	return fmt.Sprintf("stash@{%d}", i.Index)
1896+}
1897+
1898+// Title returns the title of the stash item.
1899+func (i StashItem) Title() string {
1900+	return i.Message
1901+}
1902+
1903+// Description returns the description of the stash item.
1904+func (i StashItem) Description() string {
1905+	return ""
1906+}
1907+
1908+// FilterValue implements list.Item.
1909+func (i StashItem) FilterValue() string { return i.Title() }
1910+
1911+// StashItems is a list of stash items.
1912+type StashItems []StashItem
1913+
1914+// Len implements sort.Interface.
1915+func (cl StashItems) Len() int { return len(cl) }
1916+
1917+// Swap implements sort.Interface.
1918+func (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
1919+
1920+// Less implements sort.Interface.
1921+func (cl StashItems) Less(i, j int) bool {
1922+	return cl[i].Index < cl[j].Index
1923+}
1924+
1925+// StashItemDelegate is a delegate for stash items.
1926+type StashItemDelegate struct {
1927+	common *common.Common
1928+}
1929+
1930+// Height returns the height of the stash item list. Implements list.ItemDelegate.
1931+func (d StashItemDelegate) Height() int { return 1 }
1932+
1933+// Spacing implements list.ItemDelegate.
1934+func (d StashItemDelegate) Spacing() int { return 0 }
1935+
1936+// Update implements list.ItemDelegate.
1937+func (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
1938+	item, ok := m.SelectedItem().(StashItem)
1939+	if !ok {
1940+		return nil
1941+	}
1942+
1943+	switch msg := msg.(type) {
1944+	case tea.KeyMsg:
1945+		switch {
1946+		case key.Matches(msg, d.common.KeyMap.Copy):
1947+			return copyCmd(item.Title(), fmt.Sprintf("Stash message %q copied to clipboard", item.Title()))
1948+		}
1949+	}
1950+
1951+	return nil
1952+}
1953+
1954+// Render implements list.ItemDelegate.
1955+func (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
1956+	item, ok := listItem.(StashItem)
1957+	if !ok {
1958+		return
1959+	}
1960+
1961+	s := d.common.Styles.Stash
1962+
1963+	st := s.Normal.Message
1964+	selector := " "
1965+	if index == m.Index() {
1966+		selector = "> "
1967+		st = s.Active.Message
1968+	}
1969+
1970+	selector = s.Selector.Render(selector)
1971+	title := st.Render(item.Title())
1972+	fmt.Fprint(w, d.common.Zone.Mark(
1973+		item.ID(),
1974+		common.TruncateString(fmt.Sprintf("%s%s",
1975+			selector,
1976diff --git a/server/ui/pages/selection/selection.go b/server/ui/pages/selection/selection.go
1977index b85573d3343faa69c42985df000a84d663e2317f..b84b0a33dd6cc0f681fbba9071aa13f6d2c8790a 100644
1978--- a/server/ui/pages/selection/selection.go
1979+++ b/server/ui/pages/selection/selection.go
1980@@ -201,7 +201,7 @@ func (s *Selection) Init() tea.Cmd {
1981 	sortedItems := make(Items, 0)
1982 	for _, r := range repos {
1983 		if r.Name() == ".soft-serve" {
1984-			readme, path, err := backend.Readme(r)
1985+			readme, path, err := backend.Readme(r, nil)
1986 			if err != nil {
1987 				continue
1988 			}
1989diff --git a/server/ui/styles/styles.go b/server/ui/styles/styles.go
1990index 63805bbbead4012bce1c5c6ff3c11a24448228ec..fbaab60151e696557ba5325efdcd939cdbe83307 100644
1991--- a/server/ui/styles/styles.go
1992+++ b/server/ui/styles/styles.go
1993@@ -89,16 +89,22 @@ type Styles struct {
1994 
1995 	Ref struct {
1996 		Normal struct {
1997-			Item    lipgloss.Style
1998-			ItemTag lipgloss.Style
1999+			Base     lipgloss.Style
2000+			Item     lipgloss.Style
2001+			ItemTag  lipgloss.Style
2002+			ItemDesc lipgloss.Style
2003+			ItemHash lipgloss.Style
2004 		}
2005 		Active struct {
2006-			Item    lipgloss.Style
2007-			ItemTag lipgloss.Style
2008+			Base     lipgloss.Style
2009+			Item     lipgloss.Style
2010+			ItemTag  lipgloss.Style
2011+			ItemDesc lipgloss.Style
2012+			ItemHash lipgloss.Style
2013 		}
2014 		ItemSelector lipgloss.Style
2015-		ItemBranch   lipgloss.Style
2016 		Paginator    lipgloss.Style
2017+		Selector     lipgloss.Style
2018 	}
2019 
2020 	Tree struct {
2021@@ -117,6 +123,21 @@ type Styles struct {
2022 		Selector    lipgloss.Style
2023 		FileContent lipgloss.Style
2024 		Paginator   lipgloss.Style
2025+		Blame       struct {
2026+			Hash    lipgloss.Style
2027+			Message lipgloss.Style
2028+		}
2029+	}
2030+
2031+	Stash struct {
2032+		Normal struct {
2033+			Message lipgloss.Style
2034+		}
2035+		Active struct {
2036+			Message lipgloss.Style
2037+		}
2038+		Title    lipgloss.Style
2039+		Selector lipgloss.Style
2040 	}
2041 
2042 	Spinner          lipgloss.Style
2043@@ -124,8 +145,6 @@ type Styles struct {
2044 
2045 	NoContent lipgloss.Style
2046 
2047-	NoItems lipgloss.Style
2048-
2049 	StatusBar       lipgloss.Style
2050 	StatusBarKey    lipgloss.Style
2051 	StatusBarValue  lipgloss.Style
2052@@ -137,6 +156,11 @@ type Styles struct {
2053 	TabInactive  lipgloss.Style
2054 	TabActive    lipgloss.Style
2055 	TabSeparator lipgloss.Style
2056+
2057+	Code struct {
2058+		LineDigit lipgloss.Style
2059+		LineBar   lipgloss.Style
2060+	}
2061 }
2062 
2063 // DefaultStyles returns default styles for the UI.
2064@@ -227,7 +251,7 @@ func DefaultStyles() *Styles {
2065 		Margin(1, 0)
2066 
2067 	s.Repo.Header = lipgloss.NewStyle().
2068-		Height(2).
2069+		MaxHeight(2).
2070 		Border(lipgloss.NormalBorder(), false, false, true, false).
2071 		BorderForeground(lipgloss.Color("236"))
2072 
2073@@ -348,7 +372,9 @@ func DefaultStyles() *Styles {
2074 	s.Ref.Active.Item = lipgloss.NewStyle().
2075 		Foreground(highlightColorDim)
2076 
2077-	s.Ref.ItemBranch = lipgloss.NewStyle()
2078+	s.Ref.Normal.Base = lipgloss.NewStyle()
2079+
2080+	s.Ref.Active.Base = lipgloss.NewStyle()
2081 
2082 	s.Ref.Normal.ItemTag = lipgloss.NewStyle().
2083 		Foreground(lipgloss.Color("39"))
2084@@ -361,8 +387,25 @@ func DefaultStyles() *Styles {
2085 		Bold(true).
2086 		Foreground(highlightColor)
2087 
2088+	s.Ref.Normal.ItemDesc = lipgloss.NewStyle().
2089+		Faint(true)
2090+
2091+	s.Ref.Active.ItemDesc = lipgloss.NewStyle().
2092+		Foreground(highlightColor).
2093diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go
2094index d73f5a44208a8a28a1c4624e81130918d2353909..4d25f76e95b2721c1c64c46fefa53b84c38741c0 100644
2095--- a/server/web/git_lfs.go
2096+++ b/server/web/git_lfs.go
2097@@ -683,52 +683,51 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
2098 			},
2099 		})
2100 		return
2101-	} else {
2102-		locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
2103-		if err != nil {
2104-			logger.Error("error getting locks", "err", err)
2105-			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2106-				Message: "internal server error",
2107-			})
2108-			return
2109-		}
2110+	}
2111 
2112-		lockList := make([]lfs.Lock, len(locks))
2113-		users := map[int64]models.User{}
2114-		for i, lock := range locks {
2115-			owner, ok := users[lock.UserID]
2116-			if !ok {
2117-				owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
2118-				if err != nil {
2119-					logger.Error("error getting lock owner", "err", err)
2120-					renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2121-						Message: "internal server error",
2122-					})
2123-					return
2124-				}
2125-				users[lock.UserID] = owner
2126-			}
2127+	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
2128+	if err != nil {
2129+		logger.Error("error getting locks", "err", err)
2130+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2131+			Message: "internal server error",
2132+		})
2133+		return
2134+	}
2135 
2136-			lockList[i] = lfs.Lock{
2137-				ID:       strconv.FormatInt(lock.ID, 10),
2138-				Path:     lock.Path,
2139-				LockedAt: lock.CreatedAt,
2140-				Owner: lfs.Owner{
2141-					Name: owner.Username,
2142-				},
2143+	lockList := make([]lfs.Lock, len(locks))
2144+	users := map[int64]models.User{}
2145+	for i, lock := range locks {
2146+		owner, ok := users[lock.UserID]
2147+		if !ok {
2148+			owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
2149+			if err != nil {
2150+				logger.Error("error getting lock owner", "err", err)
2151+				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
2152+					Message: "internal server error",
2153+				})
2154+				return
2155 			}
2156+			users[lock.UserID] = owner
2157 		}
2158 
2159-		resp := lfs.LockListResponse{
2160-			Locks: lockList,
2161-		}
2162-		if len(locks) == limit {
2163-			resp.NextCursor = strconv.Itoa(cursor + 1)
2164+		lockList[i] = lfs.Lock{
2165+			ID:       strconv.FormatInt(lock.ID, 10),
2166+			Path:     lock.Path,
2167+			LockedAt: lock.CreatedAt,
2168+			Owner: lfs.Owner{
2169+				Name: owner.Username,
2170+			},
2171 		}
2172+	}
2173 
2174-		renderJSON(w, http.StatusOK, resp)
2175-		return
2176+	resp := lfs.LockListResponse{
2177+		Locks: lockList,
2178 	}
2179+	if len(locks) == limit {
2180+		resp.NextCursor = strconv.Itoa(cursor + 1)
2181+	}
2182+
2183+	renderJSON(w, http.StatusOK, resp)
2184 }
2185 
2186 // POST: /<repo>.git/info/lfs/objects/locks/verify