feat: browse local repositories (#369)

Ayman Bagabas created

* 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"

Change summary

cmd/soft/browse.go                          | 301 +++++++++++++++++++
cmd/soft/migrate_config.go                  |   2 
cmd/soft/root.go                            |   3 
git/commit.go                               |  22 -
git/patch.go                                |  29 +
git/reference.go                            |  28 -
git/repo.go                                 |  32 -
git/stash.go                                |  16 +
git/tag.go                                  |   6 
git/utils.go                                |  13 
go.mod                                      |   2 
go.sum                                      |   5 
server/backend/utils.go                     |   8 
server/ssh/cmd/blob.go                      |  65 ---
server/ssh/cmd/commit.go                    |   7 
server/ssh/cmd/tree.go                      |   2 
server/ssh/session.go                       |   3 
server/ssh/ui.go                            |  19 
server/ui/common/component.go               |  18 +
server/ui/common/format.go                  |  60 +++
server/ui/common/utils.go                   |   2 
server/ui/components/code/code.go           | 200 ++++++------
server/ui/components/selector/selector.go   | 149 +++++++--
server/ui/components/statusbar/statusbar.go |  61 +--
server/ui/pages/repo/files.go               | 260 +++++++++++-----
server/ui/pages/repo/filesitem.go           |   3 
server/ui/pages/repo/log.go                 | 162 +++++----
server/ui/pages/repo/readme.go              |  70 +++-
server/ui/pages/repo/refs.go                |  70 +++
server/ui/pages/repo/refsitem.go            | 119 ++++++-
server/ui/pages/repo/repo.go                | 355 +++++++++-------------
server/ui/pages/repo/stash.go               | 279 ++++++++++++++++++
server/ui/pages/repo/stashitem.go           | 106 ++++++
server/ui/pages/selection/selection.go      |   2 
server/ui/styles/styles.go                  |  88 ++++
server/web/git_lfs.go                       |  75 ++--
36 files changed, 1,844 insertions(+), 798 deletions(-)

Detailed changes

cmd/soft/browse.go πŸ”—

@@ -0,0 +1,301 @@
+package main
+
+import (
+	"fmt"
+	"path/filepath"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/ui/common"
+	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
+	"github.com/charmbracelet/soft-serve/server/ui/pages/repo"
+	"github.com/muesli/termenv"
+	"github.com/spf13/cobra"
+)
+
+var browseCmd = &cobra.Command{
+	Use:   "browse PATH",
+	Short: "Browse a repository",
+	Args:  cobra.MaximumNArgs(1),
+	RunE: func(cmd *cobra.Command, args []string) error {
+		rp := "."
+		if len(args) > 0 {
+			rp = args[0]
+		}
+
+		abs, err := filepath.Abs(rp)
+		if err != nil {
+			return err
+		}
+
+		r, err := git.Open(abs)
+		if err != nil {
+			return fmt.Errorf("failed to open repository: %w", err)
+		}
+
+		// Bubble Tea uses Termenv default output so we have to use the same
+		// thing here.
+		output := termenv.DefaultOutput()
+		ctx := cmd.Context()
+		c := common.NewCommon(ctx, output, 0, 0)
+		comps := []common.TabComponent{
+			repo.NewReadme(c),
+			repo.NewFiles(c),
+			repo.NewLog(c),
+		}
+		if !r.IsBare {
+			comps = append(comps, repo.NewStash(c))
+		}
+		comps = append(comps, repo.NewRefs(c, git.RefsHeads), repo.NewRefs(c, git.RefsTags))
+		m := &model{
+			model:  repo.New(c, comps...),
+			repo:   repository{r},
+			common: c,
+		}
+
+		m.footer = footer.New(c, m)
+		p := tea.NewProgram(m,
+			tea.WithAltScreen(),
+			tea.WithMouseCellMotion(),
+		)
+
+		_, err = p.Run()
+		return err
+	},
+}
+
+func init() {
+	// HACK: This is a hack to hide the clone url
+	// TODO: Make this configurable
+	common.CloneCmd = func(publicURL, name string) string { return "" }
+	rootCmd.AddCommand(browseCmd)
+}
+
+type state int
+
+const (
+	startState state = iota
+	errorState
+)
+
+type model struct {
+	model      *repo.Repo
+	footer     *footer.Footer
+	repo       proto.Repository
+	common     common.Common
+	state      state
+	showFooter bool
+	error      error
+}
+
+var _ tea.Model = &model{}
+
+func (m *model) SetSize(w, h int) {
+	m.common.SetSize(w, h)
+	style := m.common.Styles.App.Copy()
+	wm := style.GetHorizontalFrameSize()
+	hm := style.GetVerticalFrameSize()
+	if m.showFooter {
+		hm += m.footer.Height()
+	}
+
+	m.footer.SetSize(w-wm, h-hm)
+	m.model.SetSize(w-wm, h-hm)
+}
+
+// ShortHelp implements help.KeyMap.
+func (m model) ShortHelp() []key.Binding {
+	switch m.state {
+	case errorState:
+		return []key.Binding{
+			m.common.KeyMap.Back,
+			m.common.KeyMap.Quit,
+			m.common.KeyMap.Help,
+		}
+	default:
+		return m.model.ShortHelp()
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (m model) FullHelp() [][]key.Binding {
+	switch m.state {
+	case errorState:
+		return [][]key.Binding{
+			{
+				m.common.KeyMap.Back,
+			},
+			{
+				m.common.KeyMap.Quit,
+				m.common.KeyMap.Help,
+			},
+		}
+	default:
+		return m.model.FullHelp()
+	}
+}
+
+// Init implements tea.Model.
+func (m *model) Init() tea.Cmd {
+	return tea.Batch(
+		m.model.Init(),
+		m.footer.Init(),
+		func() tea.Msg {
+			return repo.RepoMsg(m.repo)
+		},
+		repo.UpdateRefCmd(m.repo),
+	)
+}
+
+// Update implements tea.Model.
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	m.common.Logger.Debugf("msg received: %T", msg)
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		m.SetSize(msg.Width, msg.Height)
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.common.KeyMap.Back) && m.error != nil:
+			m.error = nil
+			m.state = startState
+			// Always show the footer on error.
+			m.showFooter = m.footer.ShowAll()
+		case key.Matches(msg, m.common.KeyMap.Help):
+			cmds = append(cmds, footer.ToggleFooterCmd)
+		case key.Matches(msg, m.common.KeyMap.Quit):
+			// Stop bubblezone background workers.
+			m.common.Zone.Close()
+			return m, tea.Quit
+		}
+	case tea.MouseMsg:
+		switch msg.Type {
+		case tea.MouseLeft:
+			switch {
+			case m.common.Zone.Get("footer").InBounds(msg):
+				cmds = append(cmds, footer.ToggleFooterCmd)
+			}
+		}
+	case footer.ToggleFooterMsg:
+		m.footer.SetShowAll(!m.footer.ShowAll())
+		m.showFooter = !m.showFooter
+	case common.ErrorMsg:
+		m.error = msg
+		m.state = errorState
+		m.showFooter = true
+	}
+
+	f, cmd := m.footer.Update(msg)
+	m.footer = f.(*footer.Footer)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	r, cmd := m.model.Update(msg)
+	m.model = r.(*repo.Repo)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+
+	// This fixes determining the height margin of the footer.
+	m.SetSize(m.common.Width, m.common.Height)
+
+	return m, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (m *model) View() string {
+	style := m.common.Styles.App.Copy()
+	wm, hm := style.GetHorizontalFrameSize(), style.GetVerticalFrameSize()
+	if m.showFooter {
+		hm += m.footer.Height()
+	}
+
+	var view string
+	switch m.state {
+	case startState:
+		view = m.model.View()
+	case errorState:
+		err := m.common.Styles.ErrorTitle.Render("Bummer")
+		err += m.common.Styles.ErrorBody.Render(m.error.Error())
+		view = m.common.Styles.Error.Copy().
+			Width(m.common.Width -
+				wm -
+				m.common.Styles.ErrorBody.GetHorizontalFrameSize()).
+			Height(m.common.Height -
+				hm -
+				m.common.Styles.Error.GetVerticalFrameSize()).
+			Render(err)
+	}
+
+	if m.showFooter {
+		view = lipgloss.JoinVertical(lipgloss.Top, view, m.footer.View())
+	}
+
+	return m.common.Zone.Scan(style.Render(view))
+}
+
+type repository struct {
+	r *git.Repository
+}
+
+var _ proto.Repository = repository{}
+
+// Description implements proto.Repository.
+func (r repository) Description() string {
+	return ""
+}
+
+// ID implements proto.Repository.
+func (r repository) ID() int64 {
+	return 0
+}
+
+// IsHidden implements proto.Repository.
+func (repository) IsHidden() bool {
+	return false
+}
+
+// IsMirror implements proto.Repository.
+func (repository) IsMirror() bool {
+	return false
+}
+
+// IsPrivate implements proto.Repository.
+func (repository) IsPrivate() bool {
+	return false
+}
+
+// Name implements proto.Repository.
+func (r repository) Name() string {
+	return filepath.Base(r.r.Path)
+}
+
+// Open implements proto.Repository.
+func (r repository) Open() (*git.Repository, error) {
+	return r.r, nil
+}
+
+// ProjectName implements proto.Repository.
+func (r repository) ProjectName() string {
+	return r.Name()
+}
+
+// UpdatedAt implements proto.Repository.
+func (r repository) UpdatedAt() time.Time {
+	t, err := r.r.LatestCommitTime()
+	if err != nil {
+		return time.Time{}
+	}
+
+	return t
+}
+
+// UserID implements proto.Repository.
+func (r repository) UserID() int64 {
+	return 0
+}

cmd/soft/migrate_config.go πŸ”—

@@ -124,7 +124,7 @@ var migrateConfig = &cobra.Command{
 			}
 		}
 
-		readme, readmePath, err := git.LatestFile(r, "README*")
+		readme, readmePath, err := git.LatestFile(r, nil, "README*")
 		hasReadme := err == nil
 
 		// Set server name

cmd/soft/root.go πŸ”—

@@ -33,6 +33,9 @@ var (
 		Short:        "A self-hostable Git server for the command line",
 		Long:         "Soft Serve is a self-hostable Git server for the command line.",
 		SilenceUsage: true,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			return browseCmd.RunE(cmd, args)
+		},
 	}
 )
 

git/commit.go πŸ”—

@@ -4,27 +4,11 @@ import (
 	"github.com/gogs/git-module"
 )
 
-// ZeroHash is the zero hash.
-var ZeroHash Hash = git.EmptyID
-
-// Hash represents a git hash.
-type Hash string
-
-// String returns the string representation of a hash as a string.
-func (h Hash) String() string {
-	return string(h)
-}
-
-// SHA1 represents the hash as a SHA1.
-func (h Hash) SHA1() *git.SHA1 {
-	return git.MustIDFromString(h.String())
-}
+// ZeroID is the zero hash.
+const ZeroID = git.EmptyID
 
 // Commit is a wrapper around git.Commit with helper methods.
-type Commit struct {
-	*git.Commit
-	Hash Hash
-}
+type Commit = git.Commit
 
 // Commits is a list of commits.
 type Commits []*Commit

git/patch.go πŸ”—

@@ -115,14 +115,14 @@ func (f *DiffFileChange) Mode() git.EntryMode {
 
 // Files returns the diff files.
 func (f *DiffFile) Files() (from *DiffFileChange, to *DiffFileChange) {
-	if f.OldIndex != ZeroHash.String() {
+	if f.OldIndex != ZeroID {
 		from = &DiffFileChange{
 			hash: f.OldIndex,
 			name: f.OldName(),
 			mode: f.OldMode(),
 		}
 	}
-	if f.Index != ZeroHash.String() {
+	if f.Index != ZeroID {
 		to = &DiffFileChange{
 			hash: f.Index,
 			name: f.Name,
@@ -298,14 +298,14 @@ func writeFilePatchHeader(sb *strings.Builder, filePatch *DiffFile) {
 		lines = append(lines,
 			fmt.Sprintf("diff --git %s %s", srcPrefix+to.Name(), dstPrefix+to.Name()),
 			fmt.Sprintf("new file mode %o", to.Mode()),
-			fmt.Sprintf("index %s..%s", ZeroHash, to.Hash()),
+			fmt.Sprintf("index %s..%s", ZeroID, to.Hash()),
 		)
 		lines = appendPathLines(lines, "/dev/null", dstPrefix+to.Name(), isBinary)
 	case to == nil:
 		lines = append(lines,
 			fmt.Sprintf("diff --git %s %s", srcPrefix+from.Name(), dstPrefix+from.Name()),
 			fmt.Sprintf("deleted file mode %o", from.Mode()),
-			fmt.Sprintf("index %s..%s", from.Hash(), ZeroHash),
+			fmt.Sprintf("index %s..%s", from.Hash(), ZeroID),
 		)
 		lines = appendPathLines(lines, srcPrefix+from.Name(), "/dev/null", isBinary)
 	}
@@ -332,3 +332,24 @@ func (d *Diff) Patch() string {
 	}
 	return p.String()
 }
+
+func toDiff(ddiff *git.Diff) *Diff {
+	files := make([]*DiffFile, 0, len(ddiff.Files))
+	for _, df := range ddiff.Files {
+		sections := make([]*DiffSection, 0, len(df.Sections))
+		for _, ds := range df.Sections {
+			sections = append(sections, &DiffSection{
+				DiffSection: ds,
+			})
+		}
+		files = append(files, &DiffFile{
+			DiffFile: df,
+			Sections: sections,
+		})
+	}
+	diff := &Diff{
+		Diff:  ddiff,
+		Files: files,
+	}
+	return diff
+}

git/reference.go πŸ”—

@@ -18,23 +18,12 @@ const (
 // Reference is a wrapper around git.Reference with helper methods.
 type Reference struct {
 	*git.Reference
-	Hash Hash
 	path string // repo path
 }
 
 // ReferenceName is a Refspec wrapper.
 type ReferenceName string
 
-// NewReference creates a new reference.
-func NewReference(rp, refspec string) *Reference {
-	return &Reference{
-		Reference: &git.Reference{
-			Refspec: refspec,
-		},
-		path: rp,
-	}
-}
-
 // String returns the reference name i.e. refs/heads/master.
 func (r ReferenceName) String() string {
 	return string(r)
@@ -42,11 +31,7 @@ func (r ReferenceName) String() string {
 
 // Short returns the short name of the reference i.e. master.
 func (r ReferenceName) Short() string {
-	s := strings.Split(r.String(), "/")
-	if len(s) > 0 {
-		return s[len(s)-1]
-	}
-	return r.String()
+	return git.RefShortName(string(r))
 }
 
 // Name returns the reference name i.e. refs/heads/master.
@@ -63,14 +48,3 @@ func (r *Reference) IsBranch() bool {
 func (r *Reference) IsTag() bool {
 	return strings.HasPrefix(r.Refspec, git.RefsTags)
 }
-
-// TargetHash returns the hash of the reference target.
-func (r *Reference) TargetHash() Hash {
-	if r.IsTag() {
-		id, err := git.ShowRefVerify(r.path, r.Refspec)
-		if err == nil {
-			return Hash(id)
-		}
-	}
-	return r.Hash
-}

git/repo.go πŸ”—

@@ -77,7 +77,6 @@ func (r *Repository) HEAD() (*Reference, error) {
 			ID:      hash,
 			Refspec: rn,
 		},
-		Hash: Hash(hash),
 		path: r.Path,
 	}, nil
 }
@@ -92,7 +91,6 @@ func (r *Repository) References() ([]*Reference, error) {
 	for _, ref := range refs {
 		rrefs = append(rrefs, &Reference{
 			Reference: ref,
-			Hash:      Hash(ref.ID),
 			path:      r.Path,
 		})
 	}
@@ -121,7 +119,7 @@ func (r *Repository) Tree(ref *Reference) (*Tree, error) {
 		}
 		ref = rref
 	}
-	return r.LsTree(ref.Hash.String())
+	return r.LsTree(ref.ID)
 }
 
 // TreePath returns the tree for the given path.
@@ -142,7 +140,7 @@ func (r *Repository) TreePath(ref *Reference, path string) (*Tree, error) {
 
 // Diff returns the diff for the given commit.
 func (r *Repository) Diff(commit *Commit) (*Diff, error) {
-	ddiff, err := r.Repository.Diff(commit.Hash.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
+	diff, err := r.Repository.Diff(commit.ID.String(), DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
 		CommandOptions: git.CommandOptions{
 			Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
 		},
@@ -150,24 +148,7 @@ func (r *Repository) Diff(commit *Commit) (*Diff, error) {
 	if err != nil {
 		return nil, err
 	}
-	files := make([]*DiffFile, 0, len(ddiff.Files))
-	for _, df := range ddiff.Files {
-		sections := make([]*DiffSection, 0, len(df.Sections))
-		for _, ds := range df.Sections {
-			sections = append(sections, &DiffSection{
-				DiffSection: ds,
-			})
-		}
-		files = append(files, &DiffFile{
-			DiffFile: df,
-			Sections: sections,
-		})
-	}
-	diff := &Diff{
-		Diff:  ddiff,
-		Files: files,
-	}
-	return diff, nil
+	return toDiff(diff), nil
 }
 
 // Patch returns the patch for the given reference.
@@ -191,12 +172,7 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
 		return nil, err
 	}
 	commits := make(Commits, len(cs))
-	for i, c := range cs {
-		commits[i] = &Commit{
-			Commit: c,
-			Hash:   Hash(c.ID.String()),
-		}
-	}
+	copy(commits, cs)
 	return commits, nil
 }
 

git/stash.go πŸ”—

@@ -0,0 +1,16 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// StashDiff returns the diff of the given stash index.
+func (r *Repository) StashDiff(index int) (*Diff, error) {
+	diff, err := r.Repository.StashDiff(index, DiffMaxFiles, DiffMaxFileLines, DiffMaxLineChars, git.DiffOptions{
+		CommandOptions: git.CommandOptions{
+			Envs: []string{"GIT_CONFIG_GLOBAL=/dev/null"},
+		},
+	})
+	if err != nil {
+		return nil, err
+	}
+	return toDiff(diff), nil
+}

git/tag.go πŸ”—

@@ -0,0 +1,6 @@
+package git
+
+import "github.com/gogs/git-module"
+
+// Tag is a git tag.
+type Tag = git.Tag

git/utils.go πŸ”—

@@ -8,14 +8,17 @@ import (
 )
 
 // LatestFile returns the contents of the first file at the specified path pattern in the repository and its file path.
-func LatestFile(repo *Repository, pattern string) (string, string, error) {
+func LatestFile(repo *Repository, ref *Reference, pattern string) (string, string, error) {
 	g := glob.MustCompile(pattern)
 	dir := filepath.Dir(pattern)
-	head, err := repo.HEAD()
-	if err != nil {
-		return "", "", err
+	if ref == nil {
+		head, err := repo.HEAD()
+		if err != nil {
+			return "", "", err
+		}
+		ref = head
 	}
-	t, err := repo.TreePath(head, dir)
+	t, err := repo.TreePath(ref, dir)
 	if err != nil {
 		return "", "", err
 	}

go.mod πŸ”—

@@ -2,6 +2,8 @@ module github.com/charmbracelet/soft-serve
 
 go 1.20
 
+replace github.com/gogs/git-module => github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213
+
 require (
 	github.com/alecthomas/chroma v0.10.0
 	github.com/charmbracelet/bubbles v0.16.1

go.sum πŸ”—

@@ -4,6 +4,8 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213 h1:/tUfPeV5T/tn2UjvQedq1incFa9B9WkFHTv0fdt5Ah0=
+github.com/aymanbagabas/git-module v1.4.1-0.20231025145308-5e8facf7a213/go.mod h1:3OBxY2gWeblk83u6BlGMO1TYDEbV4bspATMP/S2Kfsk=
 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
@@ -63,8 +65,6 @@ github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfC
 github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
-github.com/gogs/git-module v1.8.3 h1:4N9HOLzkmSfb5y4Go4f/gdt1/Z60/aQaAKr8lbsfFps=
-github.com/gogs/git-module v1.8.3/go.mod h1:yAn6ZMwh8x0u3fMotXqMP7Ct1XNNOZWNdBSBx6IFGCY=
 github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
 github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -209,7 +209,6 @@ golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfS
 golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
 golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
 golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

server/backend/utils.go πŸ”—

@@ -7,17 +7,17 @@ import (
 
 // LatestFile returns the contents of the latest file at the specified path in
 // the repository and its file path.
-func LatestFile(r proto.Repository, pattern string) (string, string, error) {
+func LatestFile(r proto.Repository, ref *git.Reference, pattern string) (string, string, error) {
 	repo, err := r.Open()
 	if err != nil {
 		return "", "", err
 	}
-	return git.LatestFile(repo, pattern)
+	return git.LatestFile(repo, ref, pattern)
 }
 
 // Readme returns the repository's README.
-func Readme(r proto.Repository) (readme string, path string, err error) {
+func Readme(r proto.Repository, ref *git.Reference) (readme string, path string, err error) {
 	pattern := "[rR][eE][aA][dD][mM][eE]*"
-	readme, path, err = LatestFile(r, pattern)
+	readme, path, err = LatestFile(r, ref, pattern)
 	return
 }

server/ssh/cmd/blob.go πŸ”—

@@ -2,29 +2,21 @@ package cmd
 
 import (
 	"fmt"
-	"strings"
 
-	"github.com/alecthomas/chroma/lexers"
-	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
-	"github.com/muesli/termenv"
+	"github.com/charmbracelet/soft-serve/server/ui/styles"
 	"github.com/spf13/cobra"
 )
 
-var (
-	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
-	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
-)
-
 // blobCommand returns a command that prints the contents of a file.
 func blobCommand() *cobra.Command {
 	var linenumber bool
 	var color bool
 	var raw bool
 
+	styles := styles.DefaultStyles()
 	cmd := &cobra.Command{
 		Use:               "blob REPOSITORY [REFERENCE] [PATH]",
 		Aliases:           []string{"cat", "show"},
@@ -60,7 +52,7 @@ func blobCommand() *cobra.Command {
 				if err != nil {
 					return err
 				}
-				ref = head.Hash.String()
+				ref = head.ID
 			}
 
 			tree, err := r.LsTree(ref)
@@ -92,14 +84,14 @@ func blobCommand() *cobra.Command {
 				}
 			} else {
 				if color {
-					c, err = withFormatting(fp, c)
+					c, err = common.FormatHighlight(fp, c)
 					if err != nil {
 						return err
 					}
 				}
 
 				if linenumber {
-					c = withLineNumber(c, color)
+					c, _ = common.FormatLineNumber(styles, c, color)
 				}
 
 				cmd.Println(c)
@@ -114,50 +106,3 @@ func blobCommand() *cobra.Command {
 
 	return cmd
 }
-
-func withLineNumber(s string, color bool) string {
-	lines := strings.Split(s, "\n")
-	// NB: len() is not a particularly safe way to count string width (because
-	// it's counting bytes instead of runes) but in this case it's okay
-	// because we're only dealing with digits, which are one byte each.
-	mll := len(fmt.Sprintf("%d", len(lines)))
-	for i, l := range lines {
-		digit := fmt.Sprintf("%*d", mll, i+1)
-		bar := "β”‚"
-		if color {
-			digit = lineDigitStyle.Render(digit)
-			bar = lineBarStyle.Render(bar)
-		}
-		if i < len(lines)-1 || len(l) != 0 {
-			// If the final line was a newline we'll get an empty string for
-			// the final line, so drop the newline altogether.
-			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
-		}
-	}
-	return strings.Join(lines, "\n")
-}
-
-func withFormatting(p, c string) (string, error) {
-	zero := uint(0)
-	lang := ""
-	lexer := lexers.Match(p)
-	if lexer != nil && lexer.Config() != nil {
-		lang = lexer.Config().Name
-	}
-	formatter := &gansi.CodeBlockElement{
-		Code:     c,
-		Language: lang,
-	}
-	r := strings.Builder{}
-	styles := common.StyleConfig()
-	styles.CodeBlock.Margin = &zero
-	rctx := gansi.NewRenderContext(gansi.Options{
-		Styles:       styles,
-		ColorProfile: termenv.TrueColor,
-	})
-	err := formatter.Render(&r, rctx)
-	if err != nil {
-		return "", err
-	}
-	return r.String(), nil
-}

server/ssh/cmd/commit.go πŸ”—

@@ -40,16 +40,11 @@ func commitCommand() *cobra.Command {
 				return err
 			}
 
-			rawCommit, err := r.CommitByRevision(commitSHA)
+			commit, err := r.CommitByRevision(commitSHA)
 			if err != nil {
 				return err
 			}
 
-			commit := &git.Commit{
-				Commit: rawCommit,
-				Hash:   git.Hash(commitSHA),
-			}
-
 			patch, err := r.Patch(commit)
 			if err != nil {
 				return err

server/ssh/cmd/tree.go πŸ”—

@@ -49,7 +49,7 @@ func treeCommand() *cobra.Command {
 					return err
 				}
 
-				ref = head.Hash.String()
+				ref = head.ID
 			}
 
 			tree, err := r.LsTree(ref)

server/ssh/session.go πŸ”—

@@ -9,7 +9,6 @@ import (
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/proto"
-	"github.com/charmbracelet/soft-serve/server/ui"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
 	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
@@ -58,7 +57,7 @@ func SessionHandler(s ssh.Session) *tea.Program {
 	output := termenv.NewOutput(s, termenv.WithColorCache(true), termenv.WithEnvironment(envs))
 	c := common.NewCommon(ctx, output, pty.Window.Width, pty.Window.Height)
 	c.SetValue(common.ConfigKey, cfg)
-	m := ui.New(c, initialRepo)
+	m := NewUI(c, initialRepo)
 	p := tea.NewProgram(m,
 		tea.WithInput(s),
 		tea.WithOutput(s),

server/ui/ui.go β†’ server/ssh/ui.go πŸ”—

@@ -1,4 +1,4 @@
-package ui
+package ssh
 
 import (
 	"errors"
@@ -7,6 +7,7 @@ import (
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
 	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
@@ -45,8 +46,8 @@ type UI struct {
 	error       error
 }
 
-// New returns a new UI model.
-func New(c common.Common, initialRepo string) *UI {
+// NewUI returns a new UI model.
+func NewUI(c common.Common, initialRepo string) *UI {
 	serverName := c.Config().Name
 	h := header.New(c, serverName)
 	ui := &UI{
@@ -133,7 +134,13 @@ func (ui *UI) SetSize(width, height int) {
 // Init implements tea.Model.
 func (ui *UI) Init() tea.Cmd {
 	ui.pages[selectionPage] = selection.New(ui.common)
-	ui.pages[repoPage] = repo.New(ui.common)
+	ui.pages[repoPage] = repo.New(ui.common,
+		repo.NewReadme(ui.common),
+		repo.NewFiles(ui.common),
+		repo.NewLog(ui.common),
+		repo.NewRefs(ui.common, git.RefsHeads),
+		repo.NewRefs(ui.common, git.RefsTags),
+	)
 	ui.SetSize(ui.common.Width, ui.common.Height)
 	cmds := make([]tea.Cmd, 0)
 	cmds = append(cmds,
@@ -273,10 +280,10 @@ func (ui *UI) View() string {
 		view = "Unknown state :/ this is a bug!"
 	}
 	if ui.activePage == selectionPage {
-		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
+		view = lipgloss.JoinVertical(lipgloss.Top, ui.header.View(), view)
 	}
 	if ui.showFooter {
-		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
+		view = lipgloss.JoinVertical(lipgloss.Top, view, ui.footer.View())
 	}
 	return ui.common.Zone.Scan(
 		ui.common.Styles.App.Render(view),

server/ui/common/component.go πŸ”—

@@ -11,3 +11,21 @@ type Component interface {
 	help.KeyMap
 	SetSize(width, height int)
 }
+
+// TabComponenet represents a model that is mounted to a tab.
+// TODO: find a better name
+type TabComponent interface {
+	Component
+
+	// StatusBarValue returns the status bar value component.
+	StatusBarValue() string
+
+	// StatusBarInfo returns the status bar info component.
+	StatusBarInfo() string
+
+	// SpinnerID returns the ID of the spinner.
+	SpinnerID() int
+
+	// TabName returns the name of the tab.
+	TabName() string
+}

server/ui/common/format.go πŸ”—

@@ -0,0 +1,60 @@
+package common
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/alecthomas/chroma/lexers"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/soft-serve/server/ui/styles"
+	"github.com/muesli/termenv"
+)
+
+// FormatLineNumber adds line numbers to a string.
+func FormatLineNumber(styles *styles.Styles, s string, color bool) (string, int) {
+	lines := strings.Split(s, "\n")
+	// NB: len() is not a particularly safe way to count string width (because
+	// it's counting bytes instead of runes) but in this case it's okay
+	// because we're only dealing with digits, which are one byte each.
+	mll := len(fmt.Sprintf("%d", len(lines)))
+	for i, l := range lines {
+		digit := fmt.Sprintf("%*d", mll, i+1)
+		bar := "β”‚"
+		if color {
+			digit = styles.Code.LineDigit.Render(digit)
+			bar = styles.Code.LineBar.Render(bar)
+		}
+		if i < len(lines)-1 || len(l) != 0 {
+			// If the final line was a newline we'll get an empty string for
+			// the final line, so drop the newline altogether.
+			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
+		}
+	}
+	return strings.Join(lines, "\n"), mll
+}
+
+// FormatHighlight adds syntax highlighting to a string.
+func FormatHighlight(p, c string) (string, error) {
+	zero := uint(0)
+	lang := ""
+	lexer := lexers.Match(p)
+	if lexer != nil && lexer.Config() != nil {
+		lang = lexer.Config().Name
+	}
+	formatter := &gansi.CodeBlockElement{
+		Code:     c,
+		Language: lang,
+	}
+	r := strings.Builder{}
+	styles := StyleConfig()
+	styles.CodeBlock.Margin = &zero
+	rctx := gansi.NewRenderContext(gansi.Options{
+		Styles:       styles,
+		ColorProfile: termenv.TrueColor,
+	})
+	err := formatter.Render(&r, rctx)
+	if err != nil {
+		return "", err
+	}
+	return r.String(), nil
+}

server/ui/common/utils.go πŸ”—

@@ -35,6 +35,6 @@ func RepoURL(publicURL, name string) string {
 }
 
 // CloneCmd returns the URL of the repository.
-func CloneCmd(publicURL, name string) string {
+var CloneCmd = func(publicURL, name string) string {
 	return fmt.Sprintf("git clone %s", RepoURL(publicURL, name))
 }

server/ui/components/code/code.go πŸ”—

@@ -1,7 +1,7 @@
 package code
 
 import (
-	"fmt"
+	"math"
 	"strings"
 	"sync"
 
@@ -16,40 +16,38 @@ import (
 )
 
 const (
-	tabWidth = 4
-)
-
-var (
-	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
-	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+	defaultTabWidth        = 4
+	defaultSideNotePercent = 0.3
 )
 
 // Code is a code snippet.
 type Code struct {
 	*vp.Viewport
-	common         common.Common
-	content        string
-	extension      string
-	renderContext  gansi.RenderContext
-	renderMutex    sync.Mutex
-	styleConfig    gansi.StyleConfig
-	showLineNumber bool
-
-	NoContentStyle lipgloss.Style
-	LineDigitStyle lipgloss.Style
-	LineBarStyle   lipgloss.Style
+	common        common.Common
+	sidenote      string
+	content       string
+	extension     string
+	renderContext gansi.RenderContext
+	renderMutex   sync.Mutex
+	styleConfig   gansi.StyleConfig
+
+	SideNotePercent float64
+	TabWidth        int
+	ShowLineNumber  bool
+	NoContentStyle  lipgloss.Style
+	UseGlamour      bool
 }
 
 // New returns a new Code.
 func New(c common.Common, content, extension string) *Code {
 	r := &Code{
-		common:         c,
-		content:        content,
-		extension:      extension,
-		Viewport:       vp.New(c),
-		NoContentStyle: c.Styles.NoContent.Copy(),
-		LineDigitStyle: lineDigitStyle,
-		LineBarStyle:   lineBarStyle,
+		common:          c,
+		content:         content,
+		extension:       extension,
+		TabWidth:        defaultTabWidth,
+		SideNotePercent: defaultSideNotePercent,
+		Viewport:        vp.New(c),
+		NoContentStyle:  c.Styles.NoContent.Copy().SetString("No Content."),
 	}
 	st := common.StyleConfig()
 	r.styleConfig = st
@@ -61,11 +59,6 @@ func New(c common.Common, content, extension string) *Code {
 	return r
 }
 
-// SetShowLineNumber sets whether to show line numbers.
-func (r *Code) SetShowLineNumber(show bool) {
-	r.showLineNumber = show
-}
-
 // SetSize implements common.Component.
 func (r *Code) SetSize(width, height int) {
 	r.common.SetSize(width, height)
@@ -79,19 +72,62 @@ func (r *Code) SetContent(c, ext string) tea.Cmd {
 	return r.Init()
 }
 
+// SetSideNote sets the sidenote of the Code.
+func (r *Code) SetSideNote(s string) tea.Cmd {
+	r.sidenote = s
+	return r.Init()
+}
+
 // Init implements tea.Model.
 func (r *Code) Init() tea.Cmd {
 	w := r.common.Width
-	c := r.content
-	if c == "" {
+	content := r.content
+	if content == "" {
 		r.Viewport.Model.SetContent(r.NoContentStyle.String())
 		return nil
 	}
-	f, err := r.renderFile(r.extension, c, w)
-	if err != nil {
-		return common.ErrorCmd(err)
+
+	// FIXME chroma & glamour might break wrapping when using tabs since tab
+	// width depends on the terminal. This is a workaround to replace tabs with
+	// 4-spaces.
+	content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", r.TabWidth))
+
+	if r.UseGlamour {
+		md, err := r.glamourize(w, content)
+		if err != nil {
+			return common.ErrorCmd(err)
+		}
+		content = md
+	} else {
+		f, err := r.renderFile(r.extension, content)
+		if err != nil {
+			return common.ErrorCmd(err)
+		}
+		content = f
+		if r.ShowLineNumber {
+			var ml int
+			content, ml = common.FormatLineNumber(r.common.Styles, content, true)
+			w -= ml
+		}
 	}
-	r.Viewport.Model.SetContent(f)
+
+	if r.sidenote != "" {
+		lines := strings.Split(r.sidenote, "\n")
+		sideNoteWidth := int(math.Ceil(float64(r.Model.Width) * r.SideNotePercent))
+		for i, l := range lines {
+			lines[i] = common.TruncateString(l, sideNoteWidth)
+		}
+		content = lipgloss.JoinHorizontal(lipgloss.Left, strings.Join(lines, "\n"), content)
+	}
+
+	// Fix styles after hard wrapping
+	// https://github.com/muesli/reflow/issues/43
+	//
+	// TODO: solve this upstream in Glamour/Reflow.
+	content = lipgloss.NewStyle().Width(w).Render(content)
+
+	r.Viewport.Model.SetContent(content)
+
 	return nil
 }
 
@@ -161,6 +197,15 @@ func (r *Code) ScrollPercent() float64 {
 	return r.Viewport.ScrollPercent()
 }
 
+// ScrollPosition returns the viewport's scroll position.
+func (r *Code) ScrollPosition() int {
+	scroll := r.ScrollPercent() * 100
+	if scroll < 0 || math.IsNaN(scroll) {
+		scroll = 0
+	}
+	return int(scroll)
+}
+
 func (r *Code) glamourize(w int, md string) (string, error) {
 	r.renderMutex.Lock()
 	defer r.renderMutex.Unlock()
@@ -182,11 +227,7 @@ func (r *Code) glamourize(w int, md string) (string, error) {
 	return mdt, nil
 }
 
-func (r *Code) renderFile(path, content string, width int) (string, error) {
-	// FIXME chroma & glamour might break wrapping when using tabs since tab
-	// width depends on the terminal. This is a workaround to replace tabs with
-	// 4-spaces.
-	content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth))
+func (r *Code) renderFile(path, content string) (string, error) {
 	lexer := lexers.Match(path)
 	if path == "" {
 		lexer = lexers.Analyse(content)
@@ -195,63 +236,26 @@ func (r *Code) renderFile(path, content string, width int) (string, error) {
 	if lexer != nil && lexer.Config() != nil {
 		lang = lexer.Config().Name
 	}
-	var c string
-	if lang == "markdown" {
-		md, err := r.glamourize(width, content)
-		if err != nil {
-			return "", err
-		}
-		c = md
-	} else {
-		formatter := &gansi.CodeBlockElement{
-			Code:     content,
-			Language: lang,
-		}
-		s := strings.Builder{}
-		rc := r.renderContext
-		if r.showLineNumber {
-			st := common.StyleConfig()
-			var m uint
-			st.CodeBlock.Margin = &m
-			rc = gansi.NewRenderContext(gansi.Options{
-				ColorProfile: termenv.TrueColor,
-				Styles:       st,
-			})
-		}
-		err := formatter.Render(&s, rc)
-		if err != nil {
-			return "", err
-		}
-		c = s.String()
-		if r.showLineNumber {
-			var ml int
-			c, ml = withLineNumber(c)
-			width -= ml
-		}
-	}
-	// Fix styling when after line breaks.
-	// https://github.com/muesli/reflow/issues/43
-	//
-	// TODO: solve this upstream in Glamour/Reflow.
-	return lipgloss.NewStyle().Width(width).Render(c), nil
-}
 
-func withLineNumber(s string) (string, int) {
-	lines := strings.Split(s, "\n")
-	// NB: len() is not a particularly safe way to count string width (because
-	// it's counting bytes instead of runes) but in this case it's okay
-	// because we're only dealing with digits, which are one byte each.
-	mll := len(fmt.Sprintf("%d", len(lines)))
-	for i, l := range lines {
-		digit := fmt.Sprintf("%*d", mll, i+1)
-		bar := "β”‚"
-		digit = lineDigitStyle.Render(digit)
-		bar = lineBarStyle.Render(bar)
-		if i < len(lines)-1 || len(l) != 0 {
-			// If the final line was a newline we'll get an empty string for
-			// the final line, so drop the newline altogether.
-			lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l)
-		}
+	formatter := &gansi.CodeBlockElement{
+		Code:     content,
+		Language: lang,
 	}
-	return strings.Join(lines, "\n"), mll
+	s := strings.Builder{}
+	rc := r.renderContext
+	if r.ShowLineNumber {
+		st := common.StyleConfig()
+		var m uint
+		st.CodeBlock.Margin = &m
+		rc = gansi.NewRenderContext(gansi.Options{
+			ColorProfile: termenv.TrueColor,
+			Styles:       st,
+		})
+	}
+	err := formatter.Render(&s, rc)
+	if err != nil {
+		return "", err
+	}
+
+	return s.String(), nil
 }

server/ui/components/selector/selector.go πŸ”—

@@ -1,6 +1,8 @@
 package selector
 
 import (
+	"sync"
+
 	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
@@ -9,10 +11,16 @@ import (
 
 // Selector is a list of items that can be selected.
 type Selector struct {
-	list.Model
+	*list.Model
 	common      common.Common
 	active      int
 	filterState list.FilterState
+
+	// XXX: we use a mutex to support concurrent access to the model. This is
+	// needed to implement pagination for the Log component. list.Model does
+	// not support item pagination so we hack it ourselves on top of
+	// list.Model.
+	mtx sync.RWMutex
 }
 
 // IdentifiableItem is an item that can be identified by a string. Implements
@@ -40,9 +48,9 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
 		itms[i] = item
 	}
 	l := list.New(itms, delegate, common.Width, common.Height)
-	l.Styles.NoItems = common.Styles.NoItems
+	l.Styles.NoItems = common.Styles.NoContent
 	s := &Selector{
-		Model:  l,
+		Model:  &l,
 		common: common,
 	}
 	s.SetSize(common.Width, common.Height)
@@ -51,66 +59,111 @@ func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate)
 
 // PerPage returns the number of items per page.
 func (s *Selector) PerPage() int {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
 	return s.Model.Paginator.PerPage
 }
 
 // SetPage sets the current page.
 func (s *Selector) SetPage(page int) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.Paginator.Page = page
 }
 
 // Page returns the current page.
 func (s *Selector) Page() int {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
 	return s.Model.Paginator.Page
 }
 
 // TotalPages returns the total number of pages.
 func (s *Selector) TotalPages() int {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
 	return s.Model.Paginator.TotalPages
 }
 
+// SetTotalPages sets the total number of pages given the number of items.
+func (s *Selector) SetTotalPages(items int) int {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
+	return s.Model.Paginator.SetTotalPages(items)
+}
+
+// SelectedItem returns the currently selected item.
+func (s *Selector) SelectedItem() IdentifiableItem {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
+	item := s.Model.SelectedItem()
+	i, ok := item.(IdentifiableItem)
+	if !ok {
+		return nil
+	}
+	return i
+}
+
 // Select selects the item at the given index.
 func (s *Selector) Select(index int) {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
 	s.Model.Select(index)
 }
 
 // SetShowTitle sets the show title flag.
 func (s *Selector) SetShowTitle(show bool) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.SetShowTitle(show)
 }
 
 // SetShowHelp sets the show help flag.
 func (s *Selector) SetShowHelp(show bool) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.SetShowHelp(show)
 }
 
 // SetShowStatusBar sets the show status bar flag.
 func (s *Selector) SetShowStatusBar(show bool) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.SetShowStatusBar(show)
 }
 
 // DisableQuitKeybindings disables the quit keybindings.
 func (s *Selector) DisableQuitKeybindings() {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.DisableQuitKeybindings()
 }
 
 // SetShowFilter sets the show filter flag.
 func (s *Selector) SetShowFilter(show bool) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.SetShowFilter(show)
 }
 
 // SetShowPagination sets the show pagination flag.
 func (s *Selector) SetShowPagination(show bool) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.SetShowPagination(show)
 }
 
 // SetFilteringEnabled sets the filtering enabled flag.
 func (s *Selector) SetFilteringEnabled(enabled bool) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.Model.SetFilteringEnabled(enabled)
 }
 
 // SetSize implements common.Component.
 func (s *Selector) SetSize(width, height int) {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	s.common.SetSize(width, height)
 	s.Model.SetSize(width, height)
 }
@@ -121,14 +174,53 @@ func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd {
 	for i, item := range items {
 		its[i] = item
 	}
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
 	return s.Model.SetItems(its)
 }
 
 // Index returns the index of the selected item.
 func (s *Selector) Index() int {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
 	return s.Model.Index()
 }
 
+// Items returns the items in the selector.
+func (s *Selector) Items() []list.Item {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
+	return s.Model.Items()
+}
+
+// VisibleItems returns all the visible items in the selector.
+func (s *Selector) VisibleItems() []list.Item {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
+	return s.Model.VisibleItems()
+}
+
+// FilterState returns the filter state.
+func (s *Selector) FilterState() list.FilterState {
+	s.mtx.RLock()
+	defer s.mtx.RUnlock()
+	return s.Model.FilterState()
+}
+
+// CursorUp moves the cursor up.
+func (s *Selector) CursorUp() {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
+	s.Model.CursorUp()
+}
+
+// CursorDown moves the cursor down.
+func (s *Selector) CursorDown() {
+	s.mtx.Lock()
+	defer s.mtx.Unlock()
+	s.Model.CursorDown()
+}
+
 // Init implements tea.Model.
 func (s *Selector) Init() tea.Cmd {
 	return s.activeCmd
@@ -141,26 +233,26 @@ func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case tea.MouseMsg:
 		switch msg.Type {
 		case tea.MouseWheelUp:
-			s.Model.CursorUp()
+			s.CursorUp()
 		case tea.MouseWheelDown:
-			s.Model.CursorDown()
+			s.CursorDown()
 		case tea.MouseLeft:
-			curIdx := s.Model.Index()
-			for i, item := range s.Model.Items() {
+			curIdx := s.Index()
+			for i, item := range s.Items() {
 				item, _ := item.(IdentifiableItem)
 				// Check each item to see if it's in bounds.
 				if item != nil && s.common.Zone.Get(item.ID()).InBounds(msg) {
 					if i == curIdx {
-						cmds = append(cmds, s.selectCmd)
+						cmds = append(cmds, s.SelectItemCmd)
 					} else {
-						s.Model.Select(i)
+						s.Select(i)
 					}
 					break
 				}
 			}
 		}
 	case tea.KeyMsg:
-		filterState := s.Model.FilterState()
+		filterState := s.FilterState()
 		switch {
 		case key.Matches(msg, s.common.KeyMap.Help):
 			if filterState == list.Filtering {
@@ -168,28 +260,30 @@ func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			}
 		case key.Matches(msg, s.common.KeyMap.Select):
 			if filterState != list.Filtering {
-				cmds = append(cmds, s.selectCmd)
+				cmds = append(cmds, s.SelectItemCmd)
 			}
 		}
 	case list.FilterMatchesMsg:
 		cmds = append(cmds, s.activeFilterCmd)
 	}
 	m, cmd := s.Model.Update(msg)
-	s.Model = m
+	s.mtx.Lock()
+	s.Model = &m
+	s.mtx.Unlock()
 	if cmd != nil {
 		cmds = append(cmds, cmd)
 	}
 	// Track filter state and update active item when filter state changes.
-	filterState := s.Model.FilterState()
+	filterState := s.FilterState()
 	if s.filterState != filterState {
 		cmds = append(cmds, s.activeFilterCmd)
 	}
 	s.filterState = filterState
 	// Send ActiveMsg when index change.
-	if s.active != s.Model.Index() {
+	if s.active != s.Index() {
 		cmds = append(cmds, s.activeCmd)
 	}
-	s.active = s.Model.Index()
+	s.active = s.Index()
 	return s, tea.Batch(cmds...)
 }
 
@@ -198,34 +292,21 @@ func (s *Selector) View() string {
 	return s.Model.View()
 }
 
-// SelectItem is a command that selects the currently active item.
-func (s *Selector) SelectItem() tea.Msg {
-	return s.selectCmd()
-}
-
-func (s *Selector) selectCmd() tea.Msg {
-	item := s.Model.SelectedItem()
-	i, ok := item.(IdentifiableItem)
-	if !ok {
-		return SelectMsg{}
-	}
-	return SelectMsg{i}
+// SelectItemCmd is a command that selects the currently active item.
+func (s *Selector) SelectItemCmd() tea.Msg {
+	return SelectMsg{s.SelectedItem()}
 }
 
 func (s *Selector) activeCmd() tea.Msg {
-	item := s.Model.SelectedItem()
-	i, ok := item.(IdentifiableItem)
-	if !ok {
-		return ActiveMsg{}
-	}
-	return ActiveMsg{i}
+	item := s.SelectedItem()
+	return ActiveMsg{item}
 }
 
 func (s *Selector) activeFilterCmd() tea.Msg {
 	// Here we use VisibleItems because when list.FilterMatchesMsg is sent,
 	// VisibleItems is the only way to get the list of filtered items. The list
 	// bubble should export something like list.FilterMatchesMsg.Items().
-	items := s.Model.VisibleItems()
+	items := s.VisibleItems()
 	if len(items) == 0 {
 		return nil
 	}

server/ui/components/statusbar/statusbar.go πŸ”—

@@ -7,16 +7,8 @@ import (
 	"github.com/muesli/reflow/truncate"
 )
 
-// StatusBarMsg is a message sent to the status bar.
-type StatusBarMsg struct { //nolint:revive
-	Key   string
-	Value string
-	Info  string
-	Extra string
-}
-
-// StatusBar is a status bar model.
-type StatusBar struct {
+// Model is a status bar model.
+type Model struct {
 	common common.Common
 	key    string
 	value  string
@@ -24,53 +16,52 @@ type StatusBar struct {
 	extra  string
 }
 
-// Model is an interface that supports setting the status bar information.
-type Model interface {
-	StatusBarValue() string
-	StatusBarInfo() string
-}
-
 // New creates a new status bar component.
-func New(c common.Common) *StatusBar {
-	s := &StatusBar{
+func New(c common.Common) *Model {
+	s := &Model{
 		common: c,
 	}
 	return s
 }
 
 // SetSize implements common.Component.
-func (s *StatusBar) SetSize(width, height int) {
+func (s *Model) SetSize(width, height int) {
 	s.common.Width = width
 	s.common.Height = height
 }
 
+// SetStatus sets the status bar status.
+func (s *Model) SetStatus(key, value, info, extra string) {
+	if key != "" {
+		s.key = key
+	}
+	if value != "" {
+		s.value = value
+	}
+	if info != "" {
+		s.info = info
+	}
+	if extra != "" {
+		s.extra = extra
+	}
+}
+
 // Init implements tea.Model.
-func (s *StatusBar) Init() tea.Cmd {
+func (s *Model) Init() tea.Cmd {
 	return nil
 }
 
 // Update implements tea.Model.
-func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (s *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
-	case StatusBarMsg:
-		if msg.Key != "" {
-			s.key = msg.Key
-		}
-		if msg.Value != "" {
-			s.value = msg.Value
-		}
-		if msg.Info != "" {
-			s.info = msg.Info
-		}
-		if msg.Extra != "" {
-			s.extra = msg.Extra
-		}
+	case tea.WindowSizeMsg:
+		s.SetSize(msg.Width, msg.Height)
 	}
 	return s, nil
 }
 
 // View implements tea.Model.
-func (s *StatusBar) View() string {
+func (s *Model) View() string {
 	st := s.common.Styles
 	w := lipgloss.Width
 	help := s.common.Zone.Mark(

server/ui/pages/repo/files.go πŸ”—

@@ -3,23 +3,26 @@ package repo
 import (
 	"errors"
 	"fmt"
-	"log"
 	"path/filepath"
+	"strings"
 
 	"github.com/alecthomas/chroma/lexers"
 	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
 	"github.com/charmbracelet/soft-serve/server/ui/components/code"
 	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
+	gitm "github.com/gogs/git-module"
 )
 
 type filesView int
 
 const (
-	filesViewFiles filesView = iota
+	filesViewLoading filesView = iota
+	filesViewFiles
 	filesViewContent
 )
 
@@ -34,6 +37,14 @@ var (
 		key.WithKeys("l"),
 		key.WithHelp("l", "toggle line numbers"),
 	)
+	blameView = key.NewBinding(
+		key.WithKeys("b"),
+		key.WithHelp("b", "toggle blame view"),
+	)
+	preview = key.NewBinding(
+		key.WithKeys("p"),
+		key.WithHelp("p", "toggle preview"),
+	)
 )
 
 // FileItemsMsg is a message that contains a list of files.
@@ -45,6 +56,9 @@ type FileContentMsg struct {
 	ext     string
 }
 
+// FileBlameMsg is a message that contains the blame of a file.
+type FileBlameMsg *gitm.Blame
+
 // Files is the model for the files view.
 type Files struct {
 	common         common.Common
@@ -56,8 +70,12 @@ type Files struct {
 	path           string
 	currentItem    *FileItem
 	currentContent FileContentMsg
+	currentBlame   FileBlameMsg
 	lastSelected   []int
 	lineNumber     bool
+	spinner        spinner.Model
+	cursor         int
+	blameView      bool
 }
 
 // NewFiles creates a new files model.
@@ -65,7 +83,7 @@ func NewFiles(common common.Common) *Files {
 	f := &Files{
 		common:       common,
 		code:         code.New(common, "", ""),
-		activeView:   filesViewFiles,
+		activeView:   filesViewLoading,
 		lastSelected: make([]int, 0),
 		lineNumber:   true,
 	}
@@ -80,10 +98,18 @@ func NewFiles(common common.Common) *Files {
 	selector.KeyMap.NextPage = common.KeyMap.NextPage
 	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
 	f.selector = selector
-	f.code.SetShowLineNumber(f.lineNumber)
+	f.code.ShowLineNumber = f.lineNumber
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(common.Styles.Spinner))
+	f.spinner = s
 	return f
 }
 
+// TabName returns the tab name.
+func (f *Files) TabName() string {
+	return "Files"
+}
+
 // SetSize implements common.Component.
 func (f *Files) SetSize(width, height int) {
 	f.common.SetSize(width, height)
@@ -96,30 +122,16 @@ func (f *Files) ShortHelp() []key.Binding {
 	k := f.selector.KeyMap
 	switch f.activeView {
 	case filesViewFiles:
-		copyKey := f.common.KeyMap.Copy
-		copyKey.SetHelp("c", "copy name")
 		return []key.Binding{
 			f.common.KeyMap.SelectItem,
 			f.common.KeyMap.BackItem,
 			k.CursorUp,
 			k.CursorDown,
-			copyKey,
 		}
 	case filesViewContent:
-		copyKey := f.common.KeyMap.Copy
-		copyKey.SetHelp("c", "copy content")
 		b := []key.Binding{
 			f.common.KeyMap.UpDown,
 			f.common.KeyMap.BackItem,
-			copyKey,
-		}
-		lexer := lexers.Match(f.currentContent.ext)
-		lang := ""
-		if lexer != nil && lexer.Config() != nil {
-			lang = lexer.Config().Name
-		}
-		if lang != "markdown" {
-			b = append(b, lineNo)
 		}
 		return b
 	default:
@@ -131,15 +143,25 @@ func (f *Files) ShortHelp() []key.Binding {
 func (f *Files) FullHelp() [][]key.Binding {
 	b := make([][]key.Binding, 0)
 	copyKey := f.common.KeyMap.Copy
+	actionKeys := []key.Binding{
+		copyKey,
+	}
+	if !f.code.UseGlamour {
+		actionKeys = append(actionKeys, lineNo)
+	}
+	actionKeys = append(actionKeys, blameView)
+	if f.isSelectedMarkdown() && !f.blameView {
+		actionKeys = append(actionKeys, preview)
+	}
 	switch f.activeView {
 	case filesViewFiles:
 		copyKey.SetHelp("c", "copy name")
 		k := f.selector.KeyMap
-		b = append(b, []key.Binding{
-			f.common.KeyMap.SelectItem,
-			f.common.KeyMap.BackItem,
-		})
 		b = append(b, [][]key.Binding{
+			{
+				f.common.KeyMap.SelectItem,
+				f.common.KeyMap.BackItem,
+			},
 			{
 				k.CursorUp,
 				k.CursorDown,
@@ -149,7 +171,6 @@ func (f *Files) FullHelp() [][]key.Binding {
 			{
 				k.GoToStart,
 				k.GoToEnd,
-				copyKey,
 			},
 		}...)
 	case filesViewContent:
@@ -165,35 +186,27 @@ func (f *Files) FullHelp() [][]key.Binding {
 				k.HalfPageDown,
 				k.HalfPageUp,
 			},
+			{
+				k.Down,
+				k.Up,
+				f.common.KeyMap.GotoTop,
+				f.common.KeyMap.GotoBottom,
+			},
 		}...)
-		lc := []key.Binding{
-			k.Down,
-			k.Up,
-			f.common.KeyMap.GotoTop,
-			f.common.KeyMap.GotoBottom,
-			copyKey,
-		}
-		lexer := lexers.Match(f.currentContent.ext)
-		lang := ""
-		if lexer != nil && lexer.Config() != nil {
-			lang = lexer.Config().Name
-		}
-		if lang != "markdown" {
-			lc = append(lc, lineNo)
-		}
-		b = append(b, lc)
 	}
-	return b
+	return append(b, actionKeys)
 }
 
 // Init implements tea.Model.
 func (f *Files) Init() tea.Cmd {
 	f.path = ""
 	f.currentItem = nil
-	f.activeView = filesViewFiles
+	f.activeView = filesViewLoading
 	f.lastSelected = make([]int, 0)
-	f.selector.Select(0)
-	return f.updateFilesCmd
+	f.blameView = false
+	f.currentBlame = nil
+	f.code.UseGlamour = false
+	return tea.Batch(f.spinner.Tick, f.updateFilesCmd)
 }
 
 // Update implements tea.Model.
@@ -204,18 +217,28 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		f.repo = msg
 	case RefMsg:
 		f.ref = msg
+		f.selector.Select(0)
 		cmds = append(cmds, f.Init())
 	case FileItemsMsg:
 		cmds = append(cmds,
 			f.selector.SetItems(msg),
-			updateStatusBarCmd,
 		)
+		f.activeView = filesViewFiles
+		if f.cursor >= 0 {
+			f.selector.Select(f.cursor)
+			f.cursor = -1
+		}
 	case FileContentMsg:
 		f.activeView = filesViewContent
 		f.currentContent = msg
-		f.code.SetContent(msg.content, msg.ext)
+		f.code.UseGlamour = f.isSelectedMarkdown()
+		cmds = append(cmds, f.code.SetContent(msg.content, msg.ext))
 		f.code.GotoTop()
-		cmds = append(cmds, updateStatusBarCmd)
+	case FileBlameMsg:
+		f.currentBlame = msg
+		f.activeView = filesViewContent
+		f.code.UseGlamour = false
+		f.code.SetSideNote(renderBlame(f.common, f.currentItem, msg))
 	case selector.SelectMsg:
 		switch sel := msg.IdentifiableItem.(type) {
 		case FileItem:
@@ -227,30 +250,47 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				cmds = append(cmds, f.selectFileCmd)
 			}
 		}
-	case BackMsg:
-		cmds = append(cmds, f.deselectItemCmd)
+	case GoBackMsg:
+		switch f.activeView {
+		case filesViewFiles, filesViewContent:
+			cmds = append(cmds, f.deselectItemCmd())
+		}
 	case tea.KeyMsg:
 		switch f.activeView {
 		case filesViewFiles:
 			switch {
 			case key.Matches(msg, f.common.KeyMap.SelectItem):
-				cmds = append(cmds, f.selector.SelectItem)
+				cmds = append(cmds, f.selector.SelectItemCmd)
 			case key.Matches(msg, f.common.KeyMap.BackItem):
-				cmds = append(cmds, backCmd)
+				cmds = append(cmds, f.deselectItemCmd())
 			}
 		case filesViewContent:
 			switch {
 			case key.Matches(msg, f.common.KeyMap.BackItem):
-				cmds = append(cmds, backCmd)
+				cmds = append(cmds, f.deselectItemCmd())
 			case key.Matches(msg, f.common.KeyMap.Copy):
 				cmds = append(cmds, copyCmd(f.currentContent.content, "File contents copied to clipboard"))
-			case key.Matches(msg, lineNo):
+			case key.Matches(msg, lineNo) && !f.code.UseGlamour:
 				f.lineNumber = !f.lineNumber
-				f.code.SetShowLineNumber(f.lineNumber)
+				f.code.ShowLineNumber = f.lineNumber
+				cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
+			case key.Matches(msg, blameView):
+				f.activeView = filesViewLoading
+				f.blameView = !f.blameView
+				if f.blameView {
+					cmds = append(cmds, f.fetchBlame)
+				} else {
+					f.activeView = filesViewContent
+					cmds = append(cmds, f.code.SetSideNote(""))
+				}
+				cmds = append(cmds, f.spinner.Tick)
+			case key.Matches(msg, preview) && f.isSelectedMarkdown() && !f.blameView:
+				f.code.UseGlamour = !f.code.UseGlamour
 				cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
 			}
 		}
 	case tea.WindowSizeMsg:
+		f.SetSize(msg.Width, msg.Height)
 		switch f.activeView {
 		case filesViewFiles:
 			if f.repo != nil {
@@ -265,8 +305,6 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 				}
 			}
 		}
-	case selector.ActiveMsg:
-		cmds = append(cmds, updateStatusBarCmd)
 	case EmptyRepoMsg:
 		f.ref = nil
 		f.path = ""
@@ -275,6 +313,14 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		f.lastSelected = make([]int, 0)
 		f.selector.Select(0)
 		cmds = append(cmds, f.setItems([]selector.IdentifiableItem{}))
+	case spinner.TickMsg:
+		if f.activeView == filesViewLoading && f.spinner.ID() == msg.ID {
+			s, cmd := f.spinner.Update(msg)
+			f.spinner = s
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	}
 	switch f.activeView {
 	case filesViewFiles:
@@ -296,6 +342,8 @@ func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 // View implements tea.Model.
 func (f *Files) View() string {
 	switch f.activeView {
+	case filesViewLoading:
+		return renderLoading(f.common, f.spinner)
 	case filesViewFiles:
 		return f.selector.View()
 	case filesViewContent:
@@ -305,11 +353,15 @@ func (f *Files) View() string {
 	}
 }
 
+// SpinnerID implements common.TabComponent.
+func (f *Files) SpinnerID() int {
+	return f.spinner.ID()
+}
+
 // StatusBarValue returns the status bar value.
 func (f *Files) StatusBarValue() string {
 	p := f.path
-	if p == "." {
-		// FIXME: this is a hack to force clear the status bar value
+	if p == "." || p == "" {
 		return " "
 	}
 	return p
@@ -321,7 +373,7 @@ func (f *Files) StatusBarInfo() string {
 	case filesViewFiles:
 		return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems()))
 	case filesViewContent:
-		return fmt.Sprintf("☰ %.f%%", f.code.ScrollPercent()*100)
+		return fmt.Sprintf("☰ %d%%", f.code.ScrollPosition())
 	default:
 		return ""
 	}
@@ -335,17 +387,17 @@ func (f *Files) updateFilesCmd() tea.Msg {
 	}
 	r, err := f.repo.Open()
 	if err != nil {
-		return common.ErrorMsg(err)
+		return common.ErrorCmd(err)
 	}
-	t, err := r.TreePath(f.ref, f.path)
+	path := f.path
+	ref := f.ref
+	t, err := r.TreePath(ref, path)
 	if err != nil {
-		log.Printf("ui: files: error getting tree %v", err)
-		return common.ErrorMsg(err)
+		return common.ErrorCmd(err)
 	}
 	ents, err := t.Entries()
 	if err != nil {
-		log.Printf("ui: files: error listing files %v", err)
-		return common.ErrorMsg(err)
+		return common.ErrorCmd(err)
 	}
 	ents.Sort()
 	for _, e := range ents {
@@ -361,10 +413,9 @@ func (f *Files) updateFilesCmd() tea.Msg {
 func (f *Files) selectTreeCmd() tea.Msg {
 	if f.currentItem != nil && f.currentItem.entry.IsTree() {
 		f.lastSelected = append(f.lastSelected, f.selector.Index())
-		f.selector.Select(0)
+		f.cursor = 0
 		return f.updateFilesCmd()
 	}
-	log.Printf("ui: files: current item is not a tree")
 	return common.ErrorMsg(errNoFileSelected)
 }
 
@@ -373,7 +424,6 @@ func (f *Files) selectFileCmd() tea.Msg {
 	if i != nil && !i.entry.IsTree() {
 		fi := i.entry.File()
 		if i.Mode().IsDir() || f == nil {
-			log.Printf("ui: files: current item is not a file")
 			return common.ErrorMsg(errInvalidFile)
 		}
 
@@ -391,32 +441,25 @@ func (f *Files) selectFileCmd() tea.Msg {
 						break
 					}
 				}
-			} else {
-				log.Printf("ui: files: error checking attributes %v", err)
 			}
-		} else {
-			log.Printf("ui: files: error opening repo %v", err)
 		}
 
 		if !bin {
 			bin, err = fi.IsBinary()
 			if err != nil {
 				f.path = filepath.Dir(f.path)
-				log.Printf("ui: files: error checking if file is binary %v", err)
 				return common.ErrorMsg(err)
 			}
 		}
 
 		if bin {
 			f.path = filepath.Dir(f.path)
-			log.Printf("ui: files: file is binary")
 			return common.ErrorMsg(errBinaryFile)
 		}
 
 		c, err := fi.Bytes()
 		if err != nil {
 			f.path = filepath.Dir(f.path)
-			log.Printf("ui: files: error reading file %v", err)
 			return common.ErrorMsg(err)
 		}
 
@@ -424,21 +467,66 @@ func (f *Files) selectFileCmd() tea.Msg {
 		return FileContentMsg{string(c), i.entry.Name()}
 	}
 
-	log.Printf("ui: files: current item is not a file")
 	return common.ErrorMsg(errNoFileSelected)
 }
 
-func (f *Files) deselectItemCmd() tea.Msg {
+func (f *Files) fetchBlame() tea.Msg {
+	r, err := f.repo.Open()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+
+	b, err := r.BlameFile(f.ref.ID, f.currentItem.entry.File().Path())
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+
+	return FileBlameMsg(b)
+}
+
+func renderBlame(c common.Common, f *FileItem, b *gitm.Blame) string {
+	if f == nil || f.entry.IsTree() || b == nil {
+		return ""
+	}
+
+	lines := make([]string, 0)
+	i := 1
+	var prev string
+	for {
+		commit := b.Line(i)
+		if commit == nil {
+			break
+		}
+		line := fmt.Sprintf("%s %s",
+			c.Styles.Tree.Blame.Hash.Render(commit.ID.String()[:7]),
+			c.Styles.Tree.Blame.Message.Render(commit.Summary()),
+		)
+		if line != prev {
+			lines = append(lines, line)
+		} else {
+			lines = append(lines, "")
+		}
+		prev = line
+		i++
+	}
+
+	return strings.Join(lines, "\n")
+}
+
+func (f *Files) deselectItemCmd() tea.Cmd {
 	f.path = filepath.Dir(f.path)
-	f.activeView = filesViewFiles
-	msg := f.updateFilesCmd()
 	index := 0
 	if len(f.lastSelected) > 0 {
 		index = f.lastSelected[len(f.lastSelected)-1]
 		f.lastSelected = f.lastSelected[:len(f.lastSelected)-1]
 	}
-	f.selector.Select(index)
-	return msg
+	f.cursor = index
+	f.activeView = filesViewFiles
+	f.code.SetSideNote("")
+	f.blameView = false
+	f.currentBlame = nil
+	f.code.UseGlamour = false
+	return f.updateFilesCmd
 }
 
 func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
@@ -446,3 +534,15 @@ func (f *Files) setItems(items []selector.IdentifiableItem) tea.Cmd {
 		return FileItemsMsg(items)
 	}
 }
+
+func (f *Files) isSelectedMarkdown() bool {
+	var lang string
+	lexer := lexers.Match(f.currentContent.ext)
+	if lexer == nil {
+		lexer = lexers.Analyse(f.currentContent.content)
+	}
+	if lexer != nil && lexer.Config() != nil {
+		lang = lexer.Config().Name
+	}
+	return lang == "markdown"
+}

server/ui/pages/repo/filesitem.go πŸ”—

@@ -60,9 +60,8 @@ func (cl FileItems) Less(i, j int) bool {
 		return true
 	} else if cl[j].entry.IsTree() {
 		return false
-	} else {
-		return cl[i].Title() < cl[j].Title()
 	}
+	return cl[i].Title() < cl[j].Title()
 }
 
 // FileItemDelegate is the delegate for the file item list.

server/ui/pages/repo/log.go πŸ”—

@@ -16,6 +16,7 @@ import (
 	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
 	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/server/ui/components/viewport"
+	"github.com/charmbracelet/soft-serve/server/ui/styles"
 	"github.com/muesli/reflow/wrap"
 	"github.com/muesli/termenv"
 )
@@ -25,7 +26,8 @@ var waitBeforeLoading = time.Millisecond * 100
 type logView int
 
 const (
-	logViewCommits logView = iota
+	logViewLoading logView = iota
+	logViewCommits
 	logViewDiff
 )
 
@@ -55,7 +57,6 @@ type Log struct {
 	selectedCommit *git.Commit
 	currentDiff    *git.Diff
 	loadingTime    time.Time
-	loading        bool
 	spinner        spinner.Model
 }
 
@@ -83,6 +84,11 @@ func NewLog(common common.Common) *Log {
 	return l
 }
 
+// TabName returns the name of the tab.
+func (l *Log) TabName() string {
+	return "Commits"
+}
+
 // SetSize implements common.Component.
 func (l *Log) SetSize(width, height int) {
 	l.common.SetSize(width, height)
@@ -163,15 +169,10 @@ func (l *Log) FullHelp() [][]key.Binding {
 
 func (l *Log) startLoading() tea.Cmd {
 	l.loadingTime = time.Now()
-	l.loading = true
+	l.activeView = logViewLoading
 	return l.spinner.Tick
 }
 
-func (l *Log) stopLoading() tea.Cmd {
-	l.loading = false
-	return updateStatusBarCmd
-}
-
 // Init implements tea.Model.
 func (l *Log) Init() tea.Cmd {
 	l.activeView = logViewCommits
@@ -179,9 +180,8 @@ func (l *Log) Init() tea.Cmd {
 	l.count = 0
 	l.activeCommit = nil
 	l.selectedCommit = nil
-	l.selector.Select(0)
 	return tea.Batch(
-		l.updateCommitsCmd,
+		l.countCommitsCmd,
 		// start loading on init
 		l.startLoading(),
 	)
@@ -195,15 +195,17 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		l.repo = msg
 	case RefMsg:
 		l.ref = msg
+		l.selector.Select(0)
 		cmds = append(cmds, l.Init())
 	case LogCountMsg:
 		l.count = int64(msg)
+		l.selector.SetTotalPages(int(msg))
+		l.selector.SetItems(make([]selector.IdentifiableItem, l.count))
+		cmds = append(cmds, l.updateCommitsCmd)
 	case LogItemsMsg:
-		cmds = append(cmds,
-			l.selector.SetItems(msg),
-			// stop loading after receiving items
-			l.stopLoading(),
-		)
+		// stop loading after receiving items
+		l.activeView = logViewCommits
+		cmds = append(cmds, l.selector.SetItems(msg))
 		l.selector.SetPage(l.nextPage)
 		l.SetSize(l.common.Width, l.common.Height)
 		i := l.selector.SelectedItem()
@@ -217,10 +219,11 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case tea.KeyMsg:
 				switch {
 				case key.Matches(kmsg, l.common.KeyMap.SelectItem):
-					cmds = append(cmds, l.selector.SelectItem)
+					cmds = append(cmds, l.selector.SelectItemCmd)
 				}
 			}
-			// This is a hack for loading commits on demand based on list.Pagination.
+			// XXX: This is a hack for loading commits on demand based on
+			// list.Pagination.
 			curPage := l.selector.Page()
 			s, cmd := l.selector.Update(msg)
 			m := s.(*selector.Selector)
@@ -239,22 +242,17 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case tea.KeyMsg:
 				switch {
 				case key.Matches(kmsg, l.common.KeyMap.BackItem):
-					cmds = append(cmds, backCmd)
+					l.goBack()
 				}
 			}
 		}
-	case BackMsg:
-		if l.activeView == logViewDiff {
-			l.activeView = logViewCommits
-			l.selectedCommit = nil
-			cmds = append(cmds, updateStatusBarCmd)
-		}
+	case GoBackMsg:
+		l.goBack()
 	case selector.ActiveMsg:
 		switch sel := msg.IdentifiableItem.(type) {
 		case LogItem:
 			l.activeCommit = sel.Commit
 		}
-		cmds = append(cmds, updateStatusBarCmd)
 	case selector.SelectMsg:
 		switch sel := msg.IdentifiableItem.(type) {
 		case LogItem:
@@ -271,26 +269,22 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		l.vp.SetContent(
 			lipgloss.JoinVertical(lipgloss.Top,
 				l.renderCommit(l.selectedCommit),
-				l.renderSummary(msg),
-				l.renderDiff(msg),
+				renderSummary(msg, l.common.Styles, l.common.Width),
+				renderDiff(msg, l.common.Width),
 			),
 		)
 		l.vp.GotoTop()
 		l.activeView = logViewDiff
-		cmds = append(cmds,
-			updateStatusBarCmd,
-			// stop loading after setting the viewport content
-			l.stopLoading(),
-		)
 	case footer.ToggleFooterMsg:
 		cmds = append(cmds, l.updateCommitsCmd)
 	case tea.WindowSizeMsg:
+		l.SetSize(msg.Width, msg.Height)
 		if l.selectedCommit != nil && l.currentDiff != nil {
 			l.vp.SetContent(
 				lipgloss.JoinVertical(lipgloss.Top,
 					l.renderCommit(l.selectedCommit),
-					l.renderSummary(l.currentDiff),
-					l.renderDiff(l.currentDiff),
+					renderSummary(l.currentDiff, l.common.Styles, l.common.Width),
+					renderDiff(l.currentDiff, l.common.Width),
 				),
 			)
 		}
@@ -304,21 +298,23 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 	case EmptyRepoMsg:
 		l.ref = nil
-		l.loading = false
 		l.activeView = logViewCommits
 		l.nextPage = 0
 		l.count = 0
 		l.activeCommit = nil
 		l.selectedCommit = nil
 		l.selector.Select(0)
-		cmds = append(cmds, l.setItems([]selector.IdentifiableItem{}))
-	}
-	if l.loading {
-		s, cmd := l.spinner.Update(msg)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
+		cmds = append(cmds,
+			l.setItems([]selector.IdentifiableItem{}),
+		)
+	case spinner.TickMsg:
+		if l.activeView == logViewLoading && l.spinner.ID() == msg.ID {
+			s, cmd := l.spinner.Update(msg)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			l.spinner = s
 		}
-		l.spinner = s
 	}
 	switch l.activeView {
 	case logViewDiff:
@@ -333,17 +329,19 @@ func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View implements tea.Model.
 func (l *Log) View() string {
-	if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
-		msg := fmt.Sprintf("%s loading commit", l.spinner.View())
-		if l.selectedCommit == nil {
-			msg += "s"
-		}
-		msg += "…"
-		return l.common.Styles.SpinnerContainer.Copy().
-			Height(l.common.Height).
-			Render(msg)
-	}
 	switch l.activeView {
+	case logViewLoading:
+		if l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) {
+			msg := fmt.Sprintf("%s loading commit", l.spinner.View())
+			if l.selectedCommit == nil {
+				msg += "s"
+			}
+			msg += "…"
+			return l.common.Styles.SpinnerContainer.Copy().
+				Height(l.common.Height).
+				Render(msg)
+		}
+		fallthrough
 	case logViewCommits:
 		return l.selector.View()
 	case logViewDiff:
@@ -353,9 +351,14 @@ func (l *Log) View() string {
 	}
 }
 
+// SpinnerID implements common.TabComponent.
+func (l *Log) SpinnerID() int {
+	return l.spinner.ID()
+}
+
 // StatusBarValue returns the status bar value.
 func (l *Log) StatusBarValue() string {
-	if l.loading {
+	if l.activeView == logViewLoading {
 		return ""
 	}
 	c := l.activeCommit
@@ -376,6 +379,11 @@ func (l *Log) StatusBarValue() string {
 // StatusBarInfo returns the status bar info.
 func (l *Log) StatusBarInfo() string {
 	switch l.activeView {
+	case logViewLoading:
+		if l.count == 0 {
+			return ""
+		}
+		fallthrough
 	case logViewCommits:
 		// We're using l.nextPage instead of l.selector.Paginator.Page because
 		// of the paginator hack above.
@@ -387,6 +395,13 @@ func (l *Log) StatusBarInfo() string {
 	}
 }
 
+func (l *Log) goBack() {
+	if l.activeView == logViewDiff {
+		l.activeView = logViewCommits
+		l.selectedCommit = nil
+	}
+}
+
 func (l *Log) countCommitsCmd() tea.Msg {
 	if l.ref == nil {
 		return nil
@@ -404,28 +419,26 @@ func (l *Log) countCommitsCmd() tea.Msg {
 }
 
 func (l *Log) updateCommitsCmd() tea.Msg {
-	count := l.count
-	if l.count == 0 {
-		switch msg := l.countCommitsCmd().(type) {
-		case common.ErrorMsg:
-			return msg
-		case LogCountMsg:
-			count = int64(msg)
-		}
-	}
 	if l.ref == nil {
 		return nil
 	}
-	items := make([]selector.IdentifiableItem, count)
-	page := l.nextPage
-	limit := l.selector.PerPage()
-	skip := page * limit
 	r, err := l.repo.Open()
 	if err != nil {
 		return common.ErrorMsg(err)
 	}
+
+	count := l.count
+	if count == 0 {
+		return LogItemsMsg([]selector.IdentifiableItem{})
+	}
+
+	page := l.nextPage
+	limit := l.selector.PerPage()
+	skip := page * limit
+	ref := l.ref
+	items := make([]selector.IdentifiableItem, count)
 	// CommitsByPage pages start at 1
-	cc, err := r.CommitsByPage(l.ref, page+1, limit)
+	cc, err := r.CommitsByPage(ref, page+1, limit)
 	if err != nil {
 		l.common.Logger.Debugf("ui: error loading commits: %v", err)
 		return common.ErrorMsg(err)
@@ -447,6 +460,9 @@ func (l *Log) selectCommitCmd(commit *git.Commit) tea.Cmd {
 }
 
 func (l *Log) loadDiffCmd() tea.Msg {
+	if l.selectedCommit == nil {
+		return nil
+	}
 	r, err := l.repo.Open()
 	if err != nil {
 		l.common.Logger.Debugf("ui: error loading diff repository: %v", err)
@@ -481,21 +497,21 @@ func (l *Log) renderCommit(c *git.Commit) string {
 	return wrap.String(s.String(), l.common.Width-2)
 }
 
-func (l *Log) renderSummary(diff *git.Diff) string {
+func renderSummary(diff *git.Diff, styles *styles.Styles, width int) string {
 	stats := strings.Split(diff.Stats().String(), "\n")
 	for i, line := range stats {
 		ch := strings.Split(line, "|")
 		if len(ch) > 1 {
 			adddel := ch[len(ch)-1]
-			adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.Log.CommitStatsAdd.Render("+"))
-			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.Log.CommitStatsDel.Render("-"))
+			adddel = strings.ReplaceAll(adddel, "+", styles.Log.CommitStatsAdd.Render("+"))
+			adddel = strings.ReplaceAll(adddel, "-", styles.Log.CommitStatsDel.Render("-"))
 			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
 		}
 	}
-	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
+	return wrap.String(strings.Join(stats, "\n"), width-2)
 }
 
-func (l *Log) renderDiff(diff *git.Diff) string {
+func renderDiff(diff *git.Diff, width int) string {
 	var s strings.Builder
 	var pr strings.Builder
 	diffChroma := &gansi.CodeBlockElement{
@@ -508,7 +524,7 @@ func (l *Log) renderDiff(diff *git.Diff) string {
 	} else {
 		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
 	}
-	return wrap.String(s.String(), l.common.Width)
+	return wrap.String(s.String(), width)
 }
 
 func (l *Log) setItems(items []selector.IdentifiableItem) tea.Cmd {

server/ui/pages/repo/readme.go πŸ”—

@@ -5,6 +5,7 @@ import (
 	"path/filepath"
 
 	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/proto"
@@ -14,7 +15,8 @@ import (
 
 // ReadmeMsg is a message sent when the readme is loaded.
 type ReadmeMsg struct {
-	Msg tea.Msg
+	Content string
+	Path    string
 }
 
 // Readme is the readme component page.
@@ -24,18 +26,30 @@ type Readme struct {
 	ref        RefMsg
 	repo       proto.Repository
 	readmePath string
+	spinner    spinner.Model
+	isLoading  bool
 }
 
 // NewReadme creates a new readme model.
 func NewReadme(common common.Common) *Readme {
 	readme := code.New(common, "", "")
 	readme.NoContentStyle = readme.NoContentStyle.Copy().SetString("No readme found.")
+	readme.UseGlamour = true
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(common.Styles.Spinner))
 	return &Readme{
-		code:   readme,
-		common: common,
+		code:      readme,
+		common:    common,
+		spinner:   s,
+		isLoading: true,
 	}
 }
 
+// TabName returns the name of the tab.
+func (r *Readme) TabName() string {
+	return "Readme"
+}
+
 // SetSize implements common.Component.
 func (r *Readme) SetSize(width, height int) {
 	r.common.SetSize(width, height)
@@ -72,7 +86,8 @@ func (r *Readme) FullHelp() [][]key.Binding {
 
 // Init implements tea.Model.
 func (r *Readme) Init() tea.Cmd {
-	return r.updateReadmeCmd
+	r.isLoading = true
+	return tea.Batch(r.spinner.Tick, r.updateReadmeCmd)
 }
 
 // Update implements tea.Model.
@@ -84,9 +99,26 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds, r.Init())
+	case tea.WindowSizeMsg:
+		r.SetSize(msg.Width, msg.Height)
 	case EmptyRepoMsg:
-		r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
-			r.repo.Name()), ".md")
+		cmds = append(cmds,
+			r.code.SetContent(defaultEmptyRepoMsg(r.common.Config(),
+				r.repo.Name()), ".md"),
+		)
+	case ReadmeMsg:
+		r.isLoading = false
+		r.readmePath = msg.Path
+		r.code.GotoTop()
+		cmds = append(cmds, r.code.SetContent(msg.Content, msg.Path))
+	case spinner.TickMsg:
+		if r.isLoading && r.spinner.ID() == msg.ID {
+			s, cmd := r.spinner.Update(msg)
+			r.spinner = s
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	}
 	c, cmd := r.code.Update(msg)
 	r.code = c.(*code.Code)
@@ -98,34 +130,38 @@ func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View implements tea.Model.
 func (r *Readme) View() string {
+	if r.isLoading {
+		return renderLoading(r.common, r.spinner)
+	}
 	return r.code.View()
 }
 
+// SpinnerID implements common.TabComponent.
+func (r *Readme) SpinnerID() int {
+	return r.spinner.ID()
+}
+
 // StatusBarValue implements statusbar.StatusBar.
 func (r *Readme) StatusBarValue() string {
 	dir := filepath.Dir(r.readmePath)
-	if dir == "." {
-		return ""
+	if dir == "." || dir == "" {
+		return " "
 	}
 	return dir
 }
 
 // StatusBarInfo implements statusbar.StatusBar.
 func (r *Readme) StatusBarInfo() string {
-	return fmt.Sprintf("☰ %.f%%", r.code.ScrollPercent()*100)
+	return fmt.Sprintf("☰ %d%%", r.code.ScrollPosition())
 }
 
 func (r *Readme) updateReadmeCmd() tea.Msg {
 	m := ReadmeMsg{}
 	if r.repo == nil {
-		return common.ErrorCmd(common.ErrMissingRepo)
-	}
-	rm, rp, _ := backend.Readme(r.repo)
-	r.readmePath = rp
-	r.code.GotoTop()
-	cmd := r.code.SetContent(rm, rp)
-	if cmd != nil {
-		m.Msg = cmd()
+		return common.ErrorMsg(common.ErrMissingRepo)
 	}
+	rm, rp, _ := backend.Readme(r.repo, r.ref)
+	m.Content = rm
+	m.Path = rp
 	return m
 }

server/ui/pages/repo/refs.go πŸ”—

@@ -6,17 +6,16 @@ import (
 	"strings"
 
 	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/git"
-	ggit "github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
 	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
-	"github.com/charmbracelet/soft-serve/server/ui/components/tabs"
 )
 
 // RefMsg is a message that contains a git.Reference.
-type RefMsg *ggit.Reference
+type RefMsg *git.Reference
 
 // RefItemsMsg is a message that contains a list of RefItem.
 type RefItemsMsg struct {
@@ -32,6 +31,8 @@ type Refs struct {
 	ref       *git.Reference
 	activeRef *git.Reference
 	refPrefix string
+	spinner   spinner.Model
+	isLoading bool
 }
 
 // NewRefs creates a new Refs component.
@@ -39,6 +40,7 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
 	r := &Refs{
 		common:    common,
 		refPrefix: refPrefix,
+		isLoading: true,
 	}
 	s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})
 	s.SetShowFilter(false)
@@ -49,9 +51,22 @@ func NewRefs(common common.Common, refPrefix string) *Refs {
 	s.SetFilteringEnabled(false)
 	s.DisableQuitKeybindings()
 	r.selector = s
+	sp := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(common.Styles.Spinner))
+	r.spinner = sp
 	return r
 }
 
+// TabName returns the name of the tab.
+func (r *Refs) TabName() string {
+	if r.refPrefix == git.RefsHeads {
+		return "Branches"
+	} else if r.refPrefix == git.RefsTags {
+		return "Tags"
+	}
+	return "Refs"
+}
+
 // SetSize implements common.Component.
 func (r *Refs) SetSize(width, height int) {
 	r.common.SetSize(width, height)
@@ -94,7 +109,8 @@ func (r *Refs) FullHelp() [][]key.Binding {
 
 // Init implements tea.Model.
 func (r *Refs) Init() tea.Cmd {
-	return r.updateItemsCmd
+	r.isLoading = true
+	return tea.Batch(r.spinner.Tick, r.updateItemsCmd)
 }
 
 // Update implements tea.Model.
@@ -107,6 +123,8 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	case RefMsg:
 		r.ref = msg
 		cmds = append(cmds, r.Init())
+	case tea.WindowSizeMsg:
+		r.SetSize(msg.Width, msg.Height)
 	case RefItemsMsg:
 		if r.refPrefix == msg.prefix {
 			cmds = append(cmds, r.selector.SetItems(msg.items))
@@ -114,29 +132,37 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			if i != nil {
 				r.activeRef = i.(RefItem).Reference
 			}
+			r.isLoading = false
 		}
 	case selector.ActiveMsg:
 		switch sel := msg.IdentifiableItem.(type) {
 		case RefItem:
 			r.activeRef = sel.Reference
 		}
-		cmds = append(cmds, updateStatusBarCmd)
 	case selector.SelectMsg:
 		switch i := msg.IdentifiableItem.(type) {
 		case RefItem:
 			cmds = append(cmds,
 				switchRefCmd(i.Reference),
-				tabs.SelectTabCmd(int(filesTab)),
+				switchTabCmd(&Files{}),
 			)
 		}
 	case tea.KeyMsg:
 		switch {
 		case key.Matches(msg, r.common.KeyMap.SelectItem):
-			cmds = append(cmds, r.selector.SelectItem)
+			cmds = append(cmds, r.selector.SelectItemCmd)
 		}
 	case EmptyRepoMsg:
 		r.ref = nil
 		cmds = append(cmds, r.setItems([]selector.IdentifiableItem{}))
+	case spinner.TickMsg:
+		if r.isLoading && r.spinner.ID() == msg.ID {
+			s, cmd := r.spinner.Update(msg)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			r.spinner = s
+		}
 	}
 	m, cmd := r.selector.Update(msg)
 	r.selector = m.(*selector.Selector)
@@ -148,9 +174,17 @@ func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 // View implements tea.Model.
 func (r *Refs) View() string {
+	if r.isLoading {
+		return renderLoading(r.common, r.spinner)
+	}
 	return r.selector.View()
 }
 
+// SpinnerID implements common.TabComponent.
+func (r *Refs) SpinnerID() int {
+	return r.spinner.ID()
+}
+
 // StatusBarValue implements statusbar.StatusBar.
 func (r *Refs) StatusBarValue() string {
 	if r.activeRef == nil {
@@ -162,10 +196,10 @@ func (r *Refs) StatusBarValue() string {
 // StatusBarInfo implements statusbar.StatusBar.
 func (r *Refs) StatusBarInfo() string {
 	totalPages := r.selector.TotalPages()
-	if totalPages > 1 {
-		return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages)
+	if totalPages <= 1 {
+		return "p. 1/1"
 	}
-	return ""
+	return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages)
 }
 
 func (r *Refs) updateItemsCmd() tea.Msg {
@@ -181,7 +215,19 @@ func (r *Refs) updateItemsCmd() tea.Msg {
 	}
 	for _, ref := range refs {
 		if strings.HasPrefix(ref.Name().String(), r.refPrefix) {
-			its = append(its, RefItem{Reference: ref})
+			refItem := RefItem{
+				Reference: ref,
+			}
+
+			if ref.IsTag() {
+				refItem.Tag, _ = rr.Tag(ref.Name().Short())
+				if refItem.Tag != nil {
+					refItem.Commit, _ = refItem.Tag.Commit()
+				}
+			} else {
+				refItem.Commit, _ = rr.CatFileCommit(ref.ID)
+			}
+			its = append(its, refItem)
 		}
 	}
 	sort.Sort(its)
@@ -204,7 +250,7 @@ func (r *Refs) setItems(items []selector.IdentifiableItem) tea.Cmd {
 	}
 }
 
-func switchRefCmd(ref *ggit.Reference) tea.Cmd {
+func switchRefCmd(ref *git.Reference) tea.Cmd {
 	return func() tea.Msg {
 		return RefMsg(ref)
 	}

server/ui/pages/repo/refsitem.go πŸ”—

@@ -3,6 +3,8 @@ package repo
 import (
 	"fmt"
 	"io"
+	"strings"
+	"time"
 
 	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
@@ -10,11 +12,15 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	"github.com/charmbracelet/soft-serve/git"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
+	"github.com/dustin/go-humanize"
+	"github.com/muesli/reflow/truncate"
 )
 
 // RefItem is a git reference item.
 type RefItem struct {
 	*git.Reference
+	*git.Tag
+	*git.Commit
 }
 
 // ID implements selector.IdentifiableItem.
@@ -51,7 +57,12 @@ func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
 
 // Less implements sort.Interface.
 func (cl RefItems) Less(i, j int) bool {
-	return cl[i].Short() < cl[j].Short()
+	if cl[i].Commit != nil && cl[j].Commit != nil {
+		return cl[i].Commit.Author.When.After(cl[j].Commit.Author.When)
+	} else if cl[i].Commit != nil && cl[j].Commit == nil {
+		return true
+	}
+	return false
 }
 
 // RefItemDelegate is the delegate for the ref item.
@@ -83,46 +94,112 @@ func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
 
 // Render implements list.ItemDelegate.
 func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.common.Styles.Ref
 	i, ok := listItem.(RefItem)
 	if !ok {
 		return
 	}
 
-	var st lipgloss.Style
-	var selector string
-
 	isTag := i.Reference.IsTag()
 	isActive := index == m.Index()
+	s := d.common.Styles.Ref
+	st := s.Normal
+	selector := "  "
+	if isActive {
+		st = s.Active
+		selector = s.ItemSelector.String()
+	}
 
+	horizontalFrameSize := st.Base.GetHorizontalFrameSize()
+	var itemSt lipgloss.Style
 	if isTag && isActive {
-		st = s.Active.ItemTag
+		itemSt = st.ItemTag
 	} else if isTag {
-		st = s.Normal.ItemTag
+		itemSt = st.ItemTag
 	} else if isActive {
-		st = s.Active.Item
+		itemSt = st.Item
 	} else {
-		st = s.Normal.Item
+		itemSt = st.Item
 	}
 
-	if isActive {
-		selector = s.ItemSelector.String()
-	} else {
-		selector = "  "
+	var sha string
+	c := i.Commit
+	if c != nil {
+		sha = c.ID.String()[:7]
 	}
 
 	ref := i.Short()
-	ref = s.ItemBranch.Render(ref)
-	refMaxWidth := m.Width() -
-		s.ItemSelector.GetMarginLeft() -
-		s.ItemSelector.GetWidth() -
-		s.Normal.Item.GetMarginLeft()
-	ref = common.TruncateString(ref, refMaxWidth)
-	ref = st.Render(ref)
+
+	var desc string
+	if isTag {
+		if c != nil {
+			date := c.Committer.When.Format("Jan 02")
+			if c.Committer.When.Year() != time.Now().Year() {
+				date += fmt.Sprintf(" %d", c.Committer.When.Year())
+			}
+			desc += " " + st.ItemDesc.Render(date)
+		}
+
+		t := i.Tag
+		if t != nil {
+			msgSt := st.ItemDesc.Copy().Faint(false)
+			msg := t.Message()
+			nl := strings.Index(msg, "\n")
+			if nl > 0 {
+				msg = msg[:nl]
+			}
+			msg = strings.TrimSpace(msg)
+			if msg != "" {
+				msgMargin := m.Width() -
+					horizontalFrameSize -
+					lipgloss.Width(selector) -
+					lipgloss.Width(ref) -
+					lipgloss.Width(desc) -
+					lipgloss.Width(sha) -
+					3 // 3 is for the paddings and truncation symbol
+				if msgMargin >= 0 {
+					msg = common.TruncateString(msg, msgMargin)
+					desc = " " + msgSt.Render(msg) + desc
+				}
+			}
+		}
+	} else if c != nil {
+		onMargin := m.Width() -
+			horizontalFrameSize -
+			lipgloss.Width(selector) -
+			lipgloss.Width(ref) -
+			lipgloss.Width(desc) -
+			lipgloss.Width(sha) -
+			2 // 2 is for the padding and truncation symbol
+		if onMargin >= 0 {
+			on := common.TruncateString("updated "+humanize.Time(c.Committer.When), onMargin)
+			desc += " " + st.ItemDesc.Render(on)
+		}
+	}
+
+	var hash string
+	ref = itemSt.Render(ref)
+	hashMargin := m.Width() -
+		horizontalFrameSize -
+		lipgloss.Width(selector) -
+		lipgloss.Width(ref) -
+		lipgloss.Width(desc) -
+		lipgloss.Width(sha) -
+		1 // 1 is for the left padding
+	if hashMargin >= 0 {
+		hash = strings.Repeat(" ", hashMargin) + st.ItemHash.Copy().
+			Align(lipgloss.Right).
+			PaddingLeft(1).
+			Render(sha)
+	}
 	fmt.Fprint(w,
 		d.common.Zone.Mark(
 			i.ID(),
-			fmt.Sprint(selector, ref),
+			st.Base.Render(
+				lipgloss.JoinHorizontal(lipgloss.Left,
+					truncate.String(selector+ref+desc+hash,
+						uint(m.Width()-horizontalFrameSize)),
+				),
+			),
 		),
 	)
 }

server/ui/pages/repo/repo.go πŸ”—

@@ -2,6 +2,7 @@ package repo
 
 import (
 	"fmt"
+	"strings"
 
 	"github.com/charmbracelet/bubbles/help"
 	"github.com/charmbracelet/bubbles/key"
@@ -12,6 +13,7 @@ import (
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/ui/common"
 	"github.com/charmbracelet/soft-serve/server/ui/components/footer"
+	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/server/ui/components/statusbar"
 	"github.com/charmbracelet/soft-serve/server/ui/components/tabs"
 )
@@ -23,41 +25,17 @@ const (
 	readyState
 )
 
-type tab int
-
-const (
-	readmeTab tab = iota
-	filesTab
-	commitsTab
-	branchesTab
-	tagsTab
-	lastTab
-)
-
-func (t tab) String() string {
-	return []string{
-		"Readme",
-		"Files",
-		"Commits",
-		"Branches",
-		"Tags",
-	}[t]
-}
-
 // EmptyRepoMsg is a message to indicate that the repository is empty.
 type EmptyRepoMsg struct{}
 
 // CopyURLMsg is a message to copy the URL of the current repository.
 type CopyURLMsg struct{}
 
-// UpdateStatusBarMsg updates the status bar.
-type UpdateStatusBarMsg struct{}
-
 // RepoMsg is a message that contains a git.Repository.
 type RepoMsg proto.Repository // nolint:revive
 
-// BackMsg is a message to go back to the previous view.
-type BackMsg struct{}
+// GoBackMsg is a message to go back to the previous view.
+type GoBackMsg struct{}
 
 // CopyMsg is a message to indicate copied text.
 type CopyMsg struct {
@@ -65,63 +43,60 @@ type CopyMsg struct {
 	Message string
 }
 
+// SwitchTabMsg is a message to switch tabs.
+type SwitchTabMsg common.TabComponent
+
 // Repo is a view for a git repository.
 type Repo struct {
 	common       common.Common
 	selectedRepo proto.Repository
-	activeTab    tab
+	activeTab    int
 	tabs         *tabs.Tabs
-	statusbar    *statusbar.StatusBar
-	panes        []common.Component
+	statusbar    *statusbar.Model
+	panes        []common.TabComponent
 	ref          *git.Reference
 	state        state
 	spinner      spinner.Model
-	panesReady   [lastTab]bool
+	panesReady   []bool
 }
 
 // New returns a new Repo.
-func New(c common.Common) *Repo {
+func New(c common.Common, comps ...common.TabComponent) *Repo {
 	sb := statusbar.New(c)
-	ts := make([]string, lastTab)
-	// Tabs must match the order of tab constants above.
-	for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
-		ts[i] = t.String()
+	ts := make([]string, 0)
+	for _, c := range comps {
+		ts = append(ts, c.TabName())
 	}
 	c.Logger = c.Logger.WithPrefix("ui.repo")
 	tb := tabs.New(c, ts)
-	readme := NewReadme(c)
-	log := NewLog(c)
-	files := NewFiles(c)
-	branches := NewRefs(c, git.RefsHeads)
-	tags := NewRefs(c, git.RefsTags)
 	// Make sure the order matches the order of tab constants above.
-	panes := []common.Component{
-		readme,
-		files,
-		log,
-		branches,
-		tags,
-	}
 	s := spinner.New(spinner.WithSpinner(spinner.Dot),
 		spinner.WithStyle(c.Styles.Spinner))
 	r := &Repo{
-		common:    c,
-		tabs:      tb,
-		statusbar: sb,
-		panes:     panes,
-		state:     loadingState,
-		spinner:   s,
+		common:     c,
+		tabs:       tb,
+		statusbar:  sb,
+		panes:      comps,
+		state:      loadingState,
+		spinner:    s,
+		panesReady: make([]bool, len(comps)),
 	}
 	return r
 }
 
-// SetSize implements common.Component.
-func (r *Repo) SetSize(width, height int) {
-	r.common.SetSize(width, height)
+func (r *Repo) getMargins() (int, int) {
+	hh := lipgloss.Height(r.headerView())
 	hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
-		r.common.Styles.Repo.Header.GetHeight() +
+		hh +
 		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
 		r.common.Styles.StatusBar.GetHeight()
+	return 0, hm
+}
+
+// SetSize implements common.Component.
+func (r *Repo) SetSize(width, height int) {
+	r.common.SetSize(width, height)
+	_, hm := r.getMargins()
 	r.tabs.SetSize(width, height-hm)
 	r.statusbar.SetSize(width, height-hm)
 	for _, p := range r.panes {
@@ -157,9 +132,12 @@ func (r *Repo) FullHelp() [][]key.Binding {
 
 // Init implements tea.View.
 func (r *Repo) Init() tea.Cmd {
+	r.state = loadingState
+	r.activeTab = 0
 	return tea.Batch(
 		r.tabs.Init(),
 		r.statusbar.Init(),
+		r.spinner.Tick,
 	)
 }
 
@@ -169,40 +147,25 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	case RepoMsg:
 		// Set the state to loading when we get a new repository.
-		r.state = loadingState
-		r.panesReady = [lastTab]bool{}
-		r.activeTab = 0
 		r.selectedRepo = msg
 		cmds = append(cmds,
-			r.tabs.Init(),
+			r.Init(),
 			// This will set the selected repo in each pane's model.
 			r.updateModels(msg),
-			r.spinner.Tick,
 		)
 	case RefMsg:
 		r.ref = msg
-		for _, p := range r.panes {
-			// Init will initiate each pane's model with its contents.
-			cmds = append(cmds, p.Init())
-		}
-		cmds = append(cmds,
-			r.updateStatusBarCmd,
-			r.updateModels(msg),
-		)
+		cmds = append(cmds, r.updateModels(msg))
+		r.state = readyState
 	case tabs.SelectTabMsg:
-		r.activeTab = tab(msg)
+		r.activeTab = int(msg)
 		t, cmd := r.tabs.Update(msg)
 		r.tabs = t.(*tabs.Tabs)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
 	case tabs.ActiveTabMsg:
-		r.activeTab = tab(msg)
-		if r.selectedRepo != nil {
-			cmds = append(cmds,
-				r.updateStatusBarCmd,
-			)
-		}
+		r.activeTab = int(msg)
 	case tea.KeyMsg, tea.MouseMsg:
 		t, cmd := r.tabs.Update(msg)
 		r.tabs = t.(*tabs.Tabs)
@@ -210,7 +173,6 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			cmds = append(cmds, cmd)
 		}
 		if r.selectedRepo != nil {
-			cmds = append(cmds, r.updateStatusBarCmd)
 			urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
 			cmd := common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
 			if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
@@ -228,7 +190,7 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			case tea.MouseRight:
 				switch {
 				case r.common.Zone.Get("repo-main").InBounds(msg):
-					cmds = append(cmds, backCmd)
+					cmds = append(cmds, goBackCmd)
 				}
 			}
 		}
@@ -237,69 +199,91 @@ func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		if cfg := r.common.Config(); cfg != nil {
 			r.common.Output.Copy(txt)
 		}
-		cmds = append(cmds, func() tea.Msg {
-			return statusbar.StatusBarMsg{
-				Value: msg.Message,
-			}
-		})
-	case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
-		cmds = append(cmds, r.updateRepo(msg))
+		r.statusbar.SetStatus("", msg.Message, "", "")
+	case ReadmeMsg:
+		cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
+	case FileItemsMsg, FileContentMsg:
+		cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
+	case LogItemsMsg, LogDiffMsg, LogCountMsg:
+		cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
+	case RefItemsMsg:
+		cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
+	case StashListMsg, StashPatchMsg:
+		cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
 	// We have two spinners, one is used to when loading the repository and the
 	// other is used when loading the log.
 	// Check if the spinner ID matches the spinner model.
 	case spinner.TickMsg:
-		switch msg.ID {
-		case r.spinner.ID():
-			if r.state == loadingState {
-				s, cmd := r.spinner.Update(msg)
-				r.spinner = s
-				if cmd != nil {
-					cmds = append(cmds, cmd)
+		if r.state == loadingState && r.spinner.ID() == msg.ID {
+			s, cmd := r.spinner.Update(msg)
+			r.spinner = s
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		} else {
+			for i, c := range r.panes {
+				if c.SpinnerID() == msg.ID {
+					m, cmd := c.Update(msg)
+					r.panes[i] = m.(common.TabComponent)
+					if cmd != nil {
+						cmds = append(cmds, cmd)
+					}
+					break
 				}
 			}
-		default:
-			cmds = append(cmds, r.updateRepo(msg))
 		}
-	case UpdateStatusBarMsg:
-		cmds = append(cmds, r.updateStatusBarCmd)
 	case tea.WindowSizeMsg:
+		r.SetSize(msg.Width, msg.Height)
 		cmds = append(cmds, r.updateModels(msg))
 	case EmptyRepoMsg:
 		r.ref = nil
 		r.state = readyState
-		cmds = append(cmds,
-			r.updateModels(msg),
-			r.updateStatusBarCmd,
-		)
+		cmds = append(cmds, r.updateModels(msg))
 	case common.ErrorMsg:
 		r.state = readyState
+	case SwitchTabMsg:
+		for i, c := range r.panes {
+			if c.TabName() == msg.TabName() {
+				cmds = append(cmds, tabs.SelectTabCmd(i))
+				break
+			}
+		}
 	}
-	s, cmd := r.statusbar.Update(msg)
-	r.statusbar = s.(*statusbar.StatusBar)
+	active := r.panes[r.activeTab]
+	m, cmd := active.Update(msg)
+	r.panes[r.activeTab] = m.(common.TabComponent)
 	if cmd != nil {
 		cmds = append(cmds, cmd)
 	}
-	m, cmd := r.panes[r.activeTab].Update(msg)
-	r.panes[r.activeTab] = m.(common.Component)
+
+	// Update the status bar on these events
+	// Must come after we've updated the active tab
+	switch msg.(type) {
+	case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg,
+		FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg,
+		LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg,
+		StashListMsg, StashPatchMsg:
+		r.setStatusBarInfo()
+	}
+
+	s, cmd := r.statusbar.Update(msg)
+	r.statusbar = s.(*statusbar.Model)
 	if cmd != nil {
 		cmds = append(cmds, cmd)
 	}
+
 	return r, tea.Batch(cmds...)
 }
 
 // View implements tea.Model.
 func (r *Repo) View() string {
-	s := r.common.Styles.Repo.Base.Copy().
-		Width(r.common.Width).
-		Height(r.common.Height)
-	repoBodyStyle := r.common.Styles.Repo.Body.Copy()
-	hm := repoBodyStyle.GetVerticalFrameSize() +
-		r.common.Styles.Repo.Header.GetHeight() +
-		r.common.Styles.Repo.Header.GetVerticalFrameSize() +
-		r.common.Styles.StatusBar.GetHeight() +
-		r.common.Styles.Tabs.GetHeight() +
+	wm, hm := r.getMargins()
+	hm += r.common.Styles.Tabs.GetHeight() +
 		r.common.Styles.Tabs.GetVerticalFrameSize()
-	mainStyle := repoBodyStyle.
+	s := r.common.Styles.Repo.Base.Copy().
+		Width(r.common.Width - wm).
+		Height(r.common.Height - hm)
+	mainStyle := r.common.Styles.Repo.Body.Copy().
 		Height(r.common.Height - hm)
 	var main string
 	var statusbar string
@@ -328,17 +312,17 @@ func (r *Repo) headerView() string {
 		return ""
 	}
 	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
-	name := r.selectedRepo.ProjectName()
-	if name == "" {
-		name = r.selectedRepo.Name()
+	header := r.selectedRepo.ProjectName()
+	if header == "" {
+		header = r.selectedRepo.Name()
 	}
-	name = r.common.Styles.Repo.HeaderName.Render(name)
-	desc := r.selectedRepo.Description()
-	if desc == "" {
-		desc = name
-		name = ""
-	} else {
-		desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
+	header = r.common.Styles.Repo.HeaderName.Render(header)
+	desc := strings.TrimSpace(r.selectedRepo.Description())
+	if desc != "" {
+		header = lipgloss.JoinVertical(lipgloss.Top,
+			header,
+			r.common.Styles.Repo.HeaderDesc.Render(desc),
+		)
 	}
 	urlStyle := r.common.Styles.URLStyle.Copy().
 		Width(r.common.Width - lipgloss.Width(desc) - 1).
@@ -352,109 +336,59 @@ func (r *Repo) headerView() string {
 		fmt.Sprintf("%s-url", r.selectedRepo.Name()),
 		urlStyle.Render(url),
 	)
+
+	header = lipgloss.JoinHorizontal(lipgloss.Left, header, url)
+
 	style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
 	return style.Render(
-		lipgloss.JoinVertical(lipgloss.Top,
-			truncate.Render(name),
-			truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
-				desc,
-				url,
-			)),
-		),
+		truncate.Render(header),
 	)
 }
 
-func (r *Repo) updateStatusBarCmd() tea.Msg {
+func (r *Repo) setStatusBarInfo() {
 	if r.selectedRepo == nil {
-		return nil
+		return
 	}
-	value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
-	info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
-	branch := "*"
+
+	active := r.panes[r.activeTab]
+	key := r.selectedRepo.Name()
+	value := active.StatusBarValue()
+	info := active.StatusBarInfo()
+	extra := "*"
 	if r.ref != nil {
-		branch += " " + r.ref.Name().Short()
-	}
-	return statusbar.StatusBarMsg{
-		Key:   r.selectedRepo.Name(),
-		Value: value,
-		Info:  info,
-		Extra: branch,
+		extra += " " + r.ref.Name().Short()
 	}
+
+	r.statusbar.SetStatus(key, value, info, extra)
 }
 
-func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
+func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
 	cmds := make([]tea.Cmd, 0)
 	for i, b := range r.panes {
-		m, cmd := b.Update(msg)
-		r.panes[i] = m.(common.Component)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
+		if b.TabName() == c.TabName() {
+			m, cmd := b.Update(msg)
+			r.panes[i] = m.(common.TabComponent)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+			break
 		}
 	}
 	return tea.Batch(cmds...)
 }
 
-func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
+func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
 	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
-		switch msg.(type) {
-		case LogItemsMsg:
-			r.panesReady[commitsTab] = true
-		}
-		l, cmd := r.panes[commitsTab].Update(msg)
-		r.panes[commitsTab] = l.(*Log)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	case FileItemsMsg:
-		r.panesReady[filesTab] = true
-		f, cmd := r.panes[filesTab].Update(msg)
-		r.panes[filesTab] = f.(*Files)
+	for i, b := range r.panes {
+		m, cmd := b.Update(msg)
+		r.panes[i] = m.(common.TabComponent)
 		if cmd != nil {
 			cmds = append(cmds, cmd)
 		}
-	case RefItemsMsg:
-		switch msg.prefix {
-		case git.RefsHeads:
-			r.panesReady[branchesTab] = true
-			b, cmd := r.panes[branchesTab].Update(msg)
-			r.panes[branchesTab] = b.(*Refs)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		case git.RefsTags:
-			r.panesReady[tagsTab] = true
-			t, cmd := r.panes[tagsTab].Update(msg)
-			r.panes[tagsTab] = t.(*Refs)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	case ReadmeMsg:
-		r.panesReady[readmeTab] = true
-	}
-	if r.isReady() {
-		r.state = readyState
 	}
 	return tea.Batch(cmds...)
 }
 
-func (r *Repo) isReady() bool {
-	ready := true
-	// We purposely ignore the log pane here because it has its own spinner.
-	for _, b := range []bool{
-		r.panesReady[filesTab], r.panesReady[branchesTab],
-		r.panesReady[tagsTab], r.panesReady[readmeTab],
-	} {
-		if !b {
-			ready = false
-			break
-		}
-	}
-	return ready
-}
-
 func copyCmd(text, msg string) tea.Cmd {
 	return func() tea.Msg {
 		return CopyMsg{
@@ -464,10 +398,19 @@ func copyCmd(text, msg string) tea.Cmd {
 	}
 }
 
-func updateStatusBarCmd() tea.Msg {
-	return UpdateStatusBarMsg{}
+func goBackCmd() tea.Msg {
+	return GoBackMsg{}
+}
+
+func switchTabCmd(m common.TabComponent) tea.Cmd {
+	return func() tea.Msg {
+		return SwitchTabMsg(m)
+	}
 }
 
-func backCmd() tea.Msg {
-	return BackMsg{}
+func renderLoading(c common.Common, s spinner.Model) string {
+	msg := fmt.Sprintf("%s loading…", s.View())
+	return c.Styles.SpinnerContainer.Copy().
+		Height(c.Height).
+		Render(msg)
 }

server/ui/pages/repo/stash.go πŸ”—

@@ -0,0 +1,279 @@
+package repo
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/ui/common"
+	"github.com/charmbracelet/soft-serve/server/ui/components/code"
+	"github.com/charmbracelet/soft-serve/server/ui/components/selector"
+	gitm "github.com/gogs/git-module"
+)
+
+type stashState int
+
+const (
+	stashStateLoading stashState = iota
+	stashStateList
+	stashStatePatch
+)
+
+// StashListMsg is a message sent when the stash list is loaded.
+type StashListMsg []*gitm.Stash
+
+// StashPatchMsg is a message sent when the stash patch is loaded.
+type StashPatchMsg struct{ *git.Diff }
+
+// Stash is the stash component page.
+type Stash struct {
+	common       common.Common
+	code         *code.Code
+	ref          RefMsg
+	repo         proto.Repository
+	spinner      spinner.Model
+	list         *selector.Selector
+	state        stashState
+	currentPatch StashPatchMsg
+}
+
+// NewStash creates a new stash model.
+func NewStash(common common.Common) *Stash {
+	code := code.New(common, "", "")
+	s := spinner.New(spinner.WithSpinner(spinner.Dot),
+		spinner.WithStyle(common.Styles.Spinner))
+	selector := selector.New(common, []selector.IdentifiableItem{}, StashItemDelegate{&common})
+	selector.SetShowFilter(false)
+	selector.SetShowHelp(false)
+	selector.SetShowPagination(false)
+	selector.SetShowStatusBar(false)
+	selector.SetShowTitle(false)
+	selector.SetFilteringEnabled(false)
+	selector.DisableQuitKeybindings()
+	selector.KeyMap.NextPage = common.KeyMap.NextPage
+	selector.KeyMap.PrevPage = common.KeyMap.PrevPage
+	return &Stash{
+		code:    code,
+		common:  common,
+		spinner: s,
+		list:    selector,
+	}
+}
+
+// TabName returns the name of the tab.
+func (s *Stash) TabName() string {
+	return "Stash"
+}
+
+// SetSize implements common.Component.
+func (s *Stash) SetSize(width, height int) {
+	s.common.SetSize(width, height)
+	s.code.SetSize(width, height)
+	s.list.SetSize(width, height)
+}
+
+// ShortHelp implements help.KeyMap.
+func (s *Stash) ShortHelp() []key.Binding {
+	return []key.Binding{
+		s.common.KeyMap.Select,
+		s.common.KeyMap.Back,
+		s.common.KeyMap.UpDown,
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (s *Stash) FullHelp() [][]key.Binding {
+	b := [][]key.Binding{
+		{
+			s.common.KeyMap.Select,
+			s.common.KeyMap.Back,
+			s.common.KeyMap.Copy,
+		},
+		{
+			s.code.KeyMap.Down,
+			s.code.KeyMap.Up,
+			s.common.KeyMap.GotoTop,
+			s.common.KeyMap.GotoBottom,
+		},
+	}
+	return b
+}
+
+// StatusBarValue implements common.Component.
+func (s *Stash) StatusBarValue() string {
+	item, ok := s.list.SelectedItem().(StashItem)
+	if !ok {
+		return " "
+	}
+	idx := s.list.Index()
+	return fmt.Sprintf("stash@{%d}: %s", idx, item.Title())
+}
+
+// StatusBarInfo implements common.Component.
+func (s *Stash) StatusBarInfo() string {
+	switch s.state {
+	case stashStateList:
+		totalPages := s.list.TotalPages()
+		if totalPages <= 1 {
+			return "p. 1/1"
+		}
+		return fmt.Sprintf("p. %d/%d", s.list.Page()+1, totalPages)
+	case stashStatePatch:
+		return fmt.Sprintf("☰ %d%%", s.code.ScrollPosition())
+	default:
+		return ""
+	}
+}
+
+// SpinnerID implements common.Component.
+func (s *Stash) SpinnerID() int {
+	return s.spinner.ID()
+}
+
+// Init initializes the model.
+func (s *Stash) Init() tea.Cmd {
+	s.state = stashStateLoading
+	return tea.Batch(s.spinner.Tick, s.fetchStash)
+}
+
+// Update updates the model.
+func (s *Stash) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		s.repo = msg
+	case RefMsg:
+		s.ref = msg
+		s.list.Select(0)
+		cmds = append(cmds, s.Init())
+	case tea.WindowSizeMsg:
+		s.SetSize(msg.Width, msg.Height)
+	case spinner.TickMsg:
+		if s.state == stashStateLoading && s.spinner.ID() == msg.ID {
+			sp, cmd := s.spinner.Update(msg)
+			s.spinner = sp
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case tea.KeyMsg:
+		switch s.state {
+		case stashStateList:
+			switch {
+			case key.Matches(msg, s.common.KeyMap.BackItem):
+				cmds = append(cmds, goBackCmd)
+			case key.Matches(msg, s.common.KeyMap.Copy):
+				cmds = append(cmds, copyCmd(s.list.SelectedItem().(StashItem).Title(), "Stash message copied to clipboard"))
+			}
+		case stashStatePatch:
+			switch {
+			case key.Matches(msg, s.common.KeyMap.BackItem):
+				cmds = append(cmds, goBackCmd)
+			case key.Matches(msg, s.common.KeyMap.Copy):
+				if s.currentPatch.Diff != nil {
+					patch := s.currentPatch.Diff
+					cmds = append(cmds, copyCmd(patch.Patch(), "Stash patch copied to clipboard"))
+				}
+			}
+		}
+	case StashListMsg:
+		s.state = stashStateList
+		items := make([]selector.IdentifiableItem, len(msg))
+		for i, stash := range msg {
+			items[i] = StashItem{stash}
+		}
+		cmds = append(cmds, s.list.SetItems(items))
+	case StashPatchMsg:
+		s.state = stashStatePatch
+		s.currentPatch = msg
+		if msg.Diff != nil {
+			title := s.common.Styles.Stash.Title.Render(s.list.SelectedItem().(StashItem).Title())
+			content := lipgloss.JoinVertical(lipgloss.Top,
+				title,
+				"",
+				renderSummary(msg.Diff, s.common.Styles, s.common.Width),
+				renderDiff(msg.Diff, s.common.Width),
+			)
+			cmds = append(cmds, s.code.SetContent(content, ".diff"))
+			s.code.GotoTop()
+		}
+	case selector.SelectMsg:
+		switch msg.IdentifiableItem.(type) {
+		case StashItem:
+			cmds = append(cmds, s.fetchStashPatch)
+		}
+	case GoBackMsg:
+		if s.state == stashStateList {
+			s.list.Select(0)
+		}
+		s.state = stashStateList
+	}
+	switch s.state {
+	case stashStateList:
+		l, cmd := s.list.Update(msg)
+		s.list = l.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case stashStatePatch:
+		c, cmd := s.code.Update(msg)
+		s.code = c.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return s, tea.Batch(cmds...)
+}
+
+// View returns the view.
+func (s *Stash) View() string {
+	switch s.state {
+	case stashStateLoading:
+		return renderLoading(s.common, s.spinner)
+	case stashStateList:
+		return s.list.View()
+	case stashStatePatch:
+		return s.code.View()
+	}
+	return ""
+}
+
+func (s *Stash) fetchStash() tea.Msg {
+	if s.repo == nil {
+		return StashListMsg(nil)
+	}
+
+	r, err := s.repo.Open()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+
+	stash, err := r.StashList()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+
+	return StashListMsg(stash)
+}
+
+func (s *Stash) fetchStashPatch() tea.Msg {
+	if s.repo == nil {
+		return StashPatchMsg{nil}
+	}
+
+	r, err := s.repo.Open()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+
+	diff, err := r.StashDiff(s.list.Index())
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+
+	return StashPatchMsg{diff}
+}

server/ui/pages/repo/stashitem.go πŸ”—

@@ -0,0 +1,106 @@
+package repo
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/server/ui/common"
+	gitm "github.com/gogs/git-module"
+)
+
+// StashItem represents a stash item.
+type StashItem struct{ *gitm.Stash }
+
+// ID returns the ID of the stash item.
+func (i StashItem) ID() string {
+	return fmt.Sprintf("stash@{%d}", i.Index)
+}
+
+// Title returns the title of the stash item.
+func (i StashItem) Title() string {
+	return i.Message
+}
+
+// Description returns the description of the stash item.
+func (i StashItem) Description() string {
+	return ""
+}
+
+// FilterValue implements list.Item.
+func (i StashItem) FilterValue() string { return i.Title() }
+
+// StashItems is a list of stash items.
+type StashItems []StashItem
+
+// Len implements sort.Interface.
+func (cl StashItems) Len() int { return len(cl) }
+
+// Swap implements sort.Interface.
+func (cl StashItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+
+// Less implements sort.Interface.
+func (cl StashItems) Less(i, j int) bool {
+	return cl[i].Index < cl[j].Index
+}
+
+// StashItemDelegate is a delegate for stash items.
+type StashItemDelegate struct {
+	common *common.Common
+}
+
+// Height returns the height of the stash item list. Implements list.ItemDelegate.
+func (d StashItemDelegate) Height() int { return 1 }
+
+// Spacing implements list.ItemDelegate.
+func (d StashItemDelegate) Spacing() int { return 0 }
+
+// Update implements list.ItemDelegate.
+func (d StashItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	item, ok := m.SelectedItem().(StashItem)
+	if !ok {
+		return nil
+	}
+
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			return copyCmd(item.Title(), fmt.Sprintf("Stash message %q copied to clipboard", item.Title()))
+		}
+	}
+
+	return nil
+}
+
+// Render implements list.ItemDelegate.
+func (d StashItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	item, ok := listItem.(StashItem)
+	if !ok {
+		return
+	}
+
+	s := d.common.Styles.Stash
+
+	st := s.Normal.Message
+	selector := " "
+	if index == m.Index() {
+		selector = "> "
+		st = s.Active.Message
+	}
+
+	selector = s.Selector.Render(selector)
+	title := st.Render(item.Title())
+	fmt.Fprint(w, d.common.Zone.Mark(
+		item.ID(),
+		common.TruncateString(fmt.Sprintf("%s%s",
+			selector,
+			title,
+		), m.Width()-
+			s.Selector.GetWidth()-
+			st.GetHorizontalFrameSize(),
+		),
+	))
+}

server/ui/pages/selection/selection.go πŸ”—

@@ -201,7 +201,7 @@ func (s *Selection) Init() tea.Cmd {
 	sortedItems := make(Items, 0)
 	for _, r := range repos {
 		if r.Name() == ".soft-serve" {
-			readme, path, err := backend.Readme(r)
+			readme, path, err := backend.Readme(r, nil)
 			if err != nil {
 				continue
 			}

server/ui/styles/styles.go πŸ”—

@@ -89,16 +89,22 @@ type Styles struct {
 
 	Ref struct {
 		Normal struct {
-			Item    lipgloss.Style
-			ItemTag lipgloss.Style
+			Base     lipgloss.Style
+			Item     lipgloss.Style
+			ItemTag  lipgloss.Style
+			ItemDesc lipgloss.Style
+			ItemHash lipgloss.Style
 		}
 		Active struct {
-			Item    lipgloss.Style
-			ItemTag lipgloss.Style
+			Base     lipgloss.Style
+			Item     lipgloss.Style
+			ItemTag  lipgloss.Style
+			ItemDesc lipgloss.Style
+			ItemHash lipgloss.Style
 		}
 		ItemSelector lipgloss.Style
-		ItemBranch   lipgloss.Style
 		Paginator    lipgloss.Style
+		Selector     lipgloss.Style
 	}
 
 	Tree struct {
@@ -117,6 +123,21 @@ type Styles struct {
 		Selector    lipgloss.Style
 		FileContent lipgloss.Style
 		Paginator   lipgloss.Style
+		Blame       struct {
+			Hash    lipgloss.Style
+			Message lipgloss.Style
+		}
+	}
+
+	Stash struct {
+		Normal struct {
+			Message lipgloss.Style
+		}
+		Active struct {
+			Message lipgloss.Style
+		}
+		Title    lipgloss.Style
+		Selector lipgloss.Style
 	}
 
 	Spinner          lipgloss.Style
@@ -124,8 +145,6 @@ type Styles struct {
 
 	NoContent lipgloss.Style
 
-	NoItems lipgloss.Style
-
 	StatusBar       lipgloss.Style
 	StatusBarKey    lipgloss.Style
 	StatusBarValue  lipgloss.Style
@@ -137,6 +156,11 @@ type Styles struct {
 	TabInactive  lipgloss.Style
 	TabActive    lipgloss.Style
 	TabSeparator lipgloss.Style
+
+	Code struct {
+		LineDigit lipgloss.Style
+		LineBar   lipgloss.Style
+	}
 }
 
 // DefaultStyles returns default styles for the UI.
@@ -227,7 +251,7 @@ func DefaultStyles() *Styles {
 		Margin(1, 0)
 
 	s.Repo.Header = lipgloss.NewStyle().
-		Height(2).
+		MaxHeight(2).
 		Border(lipgloss.NormalBorder(), false, false, true, false).
 		BorderForeground(lipgloss.Color("236"))
 
@@ -348,7 +372,9 @@ func DefaultStyles() *Styles {
 	s.Ref.Active.Item = lipgloss.NewStyle().
 		Foreground(highlightColorDim)
 
-	s.Ref.ItemBranch = lipgloss.NewStyle()
+	s.Ref.Normal.Base = lipgloss.NewStyle()
+
+	s.Ref.Active.Base = lipgloss.NewStyle()
 
 	s.Ref.Normal.ItemTag = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("39"))
@@ -361,8 +387,25 @@ func DefaultStyles() *Styles {
 		Bold(true).
 		Foreground(highlightColor)
 
+	s.Ref.Normal.ItemDesc = lipgloss.NewStyle().
+		Faint(true)
+
+	s.Ref.Active.ItemDesc = lipgloss.NewStyle().
+		Foreground(highlightColor).
+		Faint(true)
+
+	s.Ref.Normal.ItemHash = lipgloss.NewStyle().
+		Foreground(hashColor).
+		Bold(true)
+
+	s.Ref.Active.ItemHash = lipgloss.NewStyle().
+		Foreground(highlightColor).
+		Bold(true)
+
 	s.Ref.Paginator = s.Log.Paginator.Copy()
 
+	s.Ref.Selector = lipgloss.NewStyle()
+
 	s.Tree.Selector = s.Tree.Normal.FileName.Copy().
 		Width(1).
 		Foreground(selectorColor)
@@ -397,6 +440,12 @@ func DefaultStyles() *Styles {
 
 	s.Tree.Paginator = s.Log.Paginator.Copy()
 
+	s.Tree.Blame.Hash = lipgloss.NewStyle().
+		Foreground(hashColor).
+		Bold(true)
+
+	s.Tree.Blame.Message = lipgloss.NewStyle()
+
 	s.Spinner = lipgloss.NewStyle().
 		MarginTop(1).
 		MarginLeft(2).
@@ -405,15 +454,10 @@ func DefaultStyles() *Styles {
 	s.SpinnerContainer = lipgloss.NewStyle()
 
 	s.NoContent = lipgloss.NewStyle().
-		SetString("No Content.").
 		MarginTop(1).
 		MarginLeft(2).
 		Foreground(lipgloss.Color("242"))
 
-	s.NoItems = lipgloss.NewStyle().
-		MarginLeft(2).
-		Foreground(lipgloss.Color("242"))
-
 	s.StatusBar = lipgloss.NewStyle().
 		Height(1)
 
@@ -457,5 +501,21 @@ func DefaultStyles() *Styles {
 		Padding(0, 1).
 		Foreground(lipgloss.Color("238"))
 
+	s.Code.LineDigit = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+
+	s.Code.LineBar = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+
+	s.Stash.Normal.Message = lipgloss.NewStyle().MarginLeft(1)
+
+	s.Stash.Active.Message = s.Stash.Normal.Message.Copy().Foreground(selectorColor)
+
+	s.Stash.Title = lipgloss.NewStyle().
+		Foreground(hashColor).
+		Bold(true)
+
+	s.Stash.Selector = lipgloss.NewStyle().
+		Width(1).
+		Foreground(selectorColor)
+
 	return s
 }

server/web/git_lfs.go πŸ”—

@@ -683,52 +683,51 @@ func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
 			},
 		})
 		return
-	} else {
-		locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
-		if err != nil {
-			logger.Error("error getting locks", "err", err)
-			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
-				Message: "internal server error",
-			})
-			return
-		}
+	}
 
-		lockList := make([]lfs.Lock, len(locks))
-		users := map[int64]models.User{}
-		for i, lock := range locks {
-			owner, ok := users[lock.UserID]
-			if !ok {
-				owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
-				if err != nil {
-					logger.Error("error getting lock owner", "err", err)
-					renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
-						Message: "internal server error",
-					})
-					return
-				}
-				users[lock.UserID] = owner
-			}
+	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
+	if err != nil {
+		logger.Error("error getting locks", "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
 
-			lockList[i] = lfs.Lock{
-				ID:       strconv.FormatInt(lock.ID, 10),
-				Path:     lock.Path,
-				LockedAt: lock.CreatedAt,
-				Owner: lfs.Owner{
-					Name: owner.Username,
-				},
+	lockList := make([]lfs.Lock, len(locks))
+	users := map[int64]models.User{}
+	for i, lock := range locks {
+		owner, ok := users[lock.UserID]
+		if !ok {
+			owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
+			if err != nil {
+				logger.Error("error getting lock owner", "err", err)
+				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+					Message: "internal server error",
+				})
+				return
 			}
+			users[lock.UserID] = owner
 		}
 
-		resp := lfs.LockListResponse{
-			Locks: lockList,
-		}
-		if len(locks) == limit {
-			resp.NextCursor = strconv.Itoa(cursor + 1)
+		lockList[i] = lfs.Lock{
+			ID:       strconv.FormatInt(lock.ID, 10),
+			Path:     lock.Path,
+			LockedAt: lock.CreatedAt,
+			Owner: lfs.Owner{
+				Name: owner.Username,
+			},
 		}
+	}
 
-		renderJSON(w, http.StatusOK, resp)
-		return
+	resp := lfs.LockListResponse{
+		Locks: lockList,
 	}
+	if len(locks) == limit {
+		resp.NextCursor = strconv.Itoa(cursor + 1)
+	}
+
+	renderJSON(w, http.StatusOK, resp)
 }
 
 // POST: /<repo>.git/info/lfs/objects/locks/verify