feat(ui): new tui

Ayman Bagabas created

wip: new ui

wip: selector

wip: header & footer

wip: selection readme

wip: add readme

wip: copy text

wip: fix readme wrap

wip: fix errorview margin

wip: underline filtered items

wip: add selector mouse wheel support

wip: fix reset formatting on line wraps

wip: initial repo ui implementation

wip: repo header

go mod tidy

add git branch symbol

fix: selector keymap

feat: display repo commits

feat: display commit diffs

godox

fix config tests

feat: render error in app style

feat: repo files tab

feat: add refs component

fix: glitches & add refs statusbar

feat: add full help toggle

fix: error view height

wip

init ui and check for errors

fix: initial repo

fix: url style

fix: selecting repo

feat: new log item style

feat: copy over ssh

clean

feat: detect private repos

feat: indicate private repos via emoji

feat: only show private repos for admins

feat: add files content line number

fix: bold selected file size

fix: remove header from repo page

fix: comitter & authored name highlight

fix: repo last updated time

feat: add statusbar symbols

fix: no repos

fix: prevent tab out of bound error

fix: decrease help columns

fix: crooked ui size rendering on startup

fix: various improvements

* don't add line numbers to markdown files
* fix selection active item styles
* fix selection readme styles
* add selection description styles

fix: add footer padding

fix: move repo readme into its own model

* fix refs switch flickering

fix: selection item truncate string

fix: footer padding

feat: repos list title & styles

fix: server cli interface

fix: simplify ui session struct

feat: selection tabs redesign

fix: no reference nil deref error

feat: add log commits loading

fix: replace tabs with space to avoid breaking files line wrapping

clean

fix(ui): selection box position when no repos

fix: log status bar after loading

fix: use actual repo name in status bar

fix(ui): styling after line breaks

fix(ui): subtle ui changes

* hide help from repo page
* file sizes now appear on the right side

fix(ui): show "no description" when a repo doesn't have one

fix(ui): glamour line wrapping

fix(ui): show footer on error

fix(ui): truncate repo clone cmd

fix(ui): truncate git clone command on repo page

fix(ui): truncate strings on small terminal width

fix(ui): switch to files tab on window resize

fix(examples): setuid imports

clean

fix: layout misc changes

fix: wrong dockerfile background highlight

fix(ui): styles on light terminal backgrounds

fix: don't use nerdfonts symbols

fix(git): cache head commits

fix(ui): loading commits spinner text

clear log selected commit after going back to commits log view

fix: respect private repos

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

feat: update deps

Change summary

cmd/soft/serve.go                        |   2 
config/auth.go                           |   0 
config/config.go                         | 294 ++++++++++++++-
config/config_test.go                    |  34 +
config/defaults.go                       |   0 
config/git.go                            |  59 ++
config/testdata/k1.pub                   |   0 
examples/setuid/main.go                  |   2 
go.mod                                   |   7 
go.sum                                   |  12 
internal/config/config.go                | 277 ---------------
internal/config/config_test.go           |  37 --
internal/tui/bubble.go                   | 234 ------------
internal/tui/bubbles/repo/bubble.go      | 137 -------
internal/tui/bubbles/selection/bubble.go | 107 -----
internal/tui/commands.go                 | 121 ------
internal/tui/session.go                  |  37 --
server/cmd/cat.go                        |  10 
server/cmd/cmd.go                        |  21 
server/cmd/git.go                        |   6 
server/cmd/list.go                       |   2 
server/config/config.go                  |  53 ++
server/config/config_test.go             |  19 +
server/middleware.go                     |   6 
server/middleware_test.go                |   4 
server/server.go                         |  10 
server/server_test.go                    |   2 
server/session.go                        |  57 +++
tui/about/bubble.go                      | 122 ------
tui/bubble.go                            | 155 --------
tui/common/consts.go                     |  28 -
tui/common/error.go                      |  36 -
tui/common/formatter.go                  |  88 ----
tui/common/git.go                        |  16 
tui/common/help.go                       |  10 
tui/common/reset.go                      |   7 
tui/common/utils.go                      |  17 
tui/log/bubble.go                        | 383 --------------------
tui/refs/bubble.go                       | 185 ----------
tui/tree/bubble.go                       | 341 ------------------
tui/viewport/viewport_patch.go           |  24 -
ui/common/common.go                      |  22 +
ui/common/component.go                   |  13 
ui/common/error.go                       |  13 
ui/common/style.go                       |  19 +
ui/common/utils.go                       |  11 
ui/components/code/code.go               | 259 ++++++++++++++
ui/components/footer/footer.go           |  85 ++++
ui/components/header/header.go           |  44 ++
ui/components/selector/selector.go       | 222 ++++++++++++
ui/components/statusbar/statusbar.go     |  85 ++++
ui/components/tabs/tabs.go               | 101 +++++
ui/components/viewport/viewport.go       |  97 +++++
ui/git.go                                |  25 +
ui/git/git.go                            |  42 ++
ui/keymap/keymap.go                      | 205 +++++++++++
ui/pages/repo/files.go                   | 390 +++++++++++++++++++++
ui/pages/repo/filesitem.go               | 146 +++++++
ui/pages/repo/log.go                     | 476 ++++++++++++++++++++++++++
ui/pages/repo/logitem.go                 | 155 ++++++++
ui/pages/repo/readme.go                  | 114 ++++++
ui/pages/repo/refs.go                    | 196 ++++++++++
ui/pages/repo/refsitem.go                | 111 ++++++
ui/pages/repo/repo.go                    | 345 ++++++++++++++++++
ui/pages/selection/item.go               | 170 +++++++++
ui/pages/selection/selection.go          | 321 +++++++++++++++++
ui/styles/styles.go                      | 197 ++++++++--
ui/ui.go                                 | 302 ++++++++++++++++
68 files changed, 4,637 insertions(+), 2,491 deletions(-)

Detailed changes

cmd/soft/serve.go 🔗

@@ -8,8 +8,8 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/server"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/spf13/cobra"
 )
 

config/config.go 🔗

@@ -1,53 +1,279 @@
 package config
 
 import (
+	"bytes"
+	"errors"
+	"io/fs"
 	"log"
 	"path/filepath"
+	"strings"
+	"sync"
+	"text/template"
+	"time"
 
-	"github.com/caarlos0/env/v6"
+	"golang.org/x/crypto/ssh"
+	"gopkg.in/yaml.v3"
+
+	"fmt"
+	"os"
+
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/go-git/go-billy/v5/memfs"
+	ggit "github.com/go-git/go-git/v5"
+	"github.com/go-git/go-git/v5/plumbing/object"
+	"github.com/go-git/go-git/v5/plumbing/transport"
+	"github.com/go-git/go-git/v5/storage/memory"
 )
 
-// Callbacks provides an interface that can be used to run callbacks on different events.
-type Callbacks interface {
-	Tui(action string)
-	Push(repo string)
-	Fetch(repo string)
+// Config is the Soft Serve configuration.
+type Config struct {
+	Name         string         `yaml:"name"`
+	Host         string         `yaml:"host"`
+	Port         int            `yaml:"port"`
+	AnonAccess   string         `yaml:"anon-access"`
+	AllowKeyless bool           `yaml:"allow-keyless"`
+	Users        []User         `yaml:"users"`
+	Repos        []MenuRepo     `yaml:"repos"`
+	Source       *RepoSource    `yaml:"-"`
+	Cfg          *config.Config `yaml:"-"`
+	mtx          sync.Mutex
 }
 
-// Config is the configuration for Soft Serve.
-type Config struct {
-	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
-	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
-	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
-	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
-	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
-	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
-	Callbacks        Callbacks
-	ErrorLog         *log.Logger
+// User contains user-level configuration for a repository.
+type User struct {
+	Name        string   `yaml:"name"`
+	Admin       bool     `yaml:"admin"`
+	PublicKeys  []string `yaml:"public-keys"`
+	CollabRepos []string `yaml:"collab-repos"`
 }
 
-// DefaultConfig returns a Config with the values populated with the defaults
-// or specified environment variables.
-func DefaultConfig() *Config {
-	cfg := &Config{ErrorLog: log.Default()}
-	if err := env.Parse(cfg); err != nil {
-		log.Fatalln(err)
+// Repo contains repository configuration information.
+type MenuRepo struct {
+	Name    string `yaml:"name"`
+	Repo    string `yaml:"repo"`
+	Note    string `yaml:"note"`
+	Private bool   `yaml:"private"`
+	Readme  string `yaml:"readme"`
+}
+
+// NewConfig creates a new internal Config struct.
+func NewConfig(cfg *config.Config) (*Config, error) {
+	var anonAccess string
+	var yamlUsers string
+	var displayHost string
+	host := cfg.Host
+	port := cfg.Port
+
+	pks := make([]string, 0)
+	for _, k := range cfg.InitialAdminKeys {
+		if bts, err := os.ReadFile(k); err == nil {
+			// pk is a file, set its contents as pk
+			k = string(bts)
+		}
+		var pk = strings.TrimSpace(k)
+		if pk == "" {
+			continue
+		}
+		// it is a valid ssh key, nothing to do
+		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
+			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
+		}
+		pks = append(pks, pk)
 	}
-	if cfg.KeyPath == "" {
-		// NB: cross-platform-compatible path
-		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
+
+	rs := NewRepoSource(cfg.RepoPath)
+	c := &Config{
+		Cfg: cfg,
 	}
-	return cfg.WithCallbacks(nil)
+	c.Host = cfg.Host
+	c.Port = port
+	c.Source = rs
+	if len(pks) == 0 {
+		anonAccess = "read-write"
+	} else {
+		anonAccess = "no-access"
+	}
+	if host == "" {
+		displayHost = "localhost"
+	} else {
+		displayHost = host
+	}
+	yamlConfig := fmt.Sprintf(defaultConfig,
+		displayHost,
+		port,
+		anonAccess,
+		len(pks) == 0,
+	)
+	if len(pks) == 0 {
+		yamlUsers = defaultUserConfig
+	} else {
+		var result string
+		for _, pk := range pks {
+			result += fmt.Sprintf("      - %s\n", pk)
+		}
+		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
+	}
+	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
+	err := c.createDefaultConfigRepo(yaml)
+	if err != nil {
+		return nil, err
+	}
+	return c, nil
 }
 
-// WithCallbacks applies the given Callbacks to the configuration.
-func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
-	c.Callbacks = callbacks
-	return c
+// Reload reloads the configuration.
+func (cfg *Config) Reload() error {
+	cfg.mtx.Lock()
+	defer cfg.mtx.Unlock()
+	err := cfg.Source.LoadRepos()
+	if err != nil {
+		return err
+	}
+	cr, err := cfg.Source.GetRepo("config")
+	if err != nil {
+		return err
+	}
+	cs, _, err := cr.LatestFile("config.yaml")
+	if err != nil {
+		return err
+	}
+	err = yaml.Unmarshal([]byte(cs), cfg)
+	if err != nil {
+		return fmt.Errorf("bad yaml in config.yaml: %s", err)
+	}
+	for _, r := range cfg.Source.AllRepos() {
+		name := r.Name()
+		err = r.UpdateServerInfo()
+		if err != nil {
+			log.Printf("error updating server info for %s: %s", name, err)
+		}
+		pat := "README*"
+		rp := ""
+		for _, rr := range cfg.Repos {
+			if name == rr.Repo {
+				rp = rr.Readme
+				r.name = rr.Name
+				r.description = rr.Note
+				r.private = rr.Private
+				break
+			}
+		}
+		if rp != "" {
+			pat = rp
+		}
+		rm := ""
+		fc, fp, _ := r.LatestFile(pat)
+		rm = fc
+		if name == "config" {
+			md, err := templatize(rm, cfg)
+			if err != nil {
+				return err
+			}
+			rm = md
+		}
+		r.SetReadme(rm, fp)
+	}
+	return nil
+}
+
+func createFile(path string, content string) error {
+	f, err := os.Create(path)
+	if err != nil {
+		return err
+	}
+	defer f.Close()
+	_, err = f.WriteString(content)
+	if err != nil {
+		return err
+	}
+	return f.Sync()
+}
+
+func (cfg *Config) createDefaultConfigRepo(yaml string) error {
+	cn := "config"
+	rp := filepath.Join(cfg.Cfg.RepoPath, cn)
+	rs := cfg.Source
+	err := rs.LoadRepo(cn)
+	if errors.Is(err, fs.ErrNotExist) {
+		repo, err := ggit.PlainInit(rp, true)
+		if err != nil {
+			return err
+		}
+		repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
+			URL: rp,
+		})
+		if err != nil && err != transport.ErrEmptyRemoteRepository {
+			return err
+		}
+		wt, err := repo.Worktree()
+		if err != nil {
+			return err
+		}
+		rm, err := wt.Filesystem.Create("README.md")
+		if err != nil {
+			return err
+		}
+		_, err = rm.Write([]byte(defaultReadme))
+		if err != nil {
+			return err
+		}
+		_, err = wt.Add("README.md")
+		if err != nil {
+			return err
+		}
+		cf, err := wt.Filesystem.Create("config.yaml")
+		if err != nil {
+			return err
+		}
+		_, err = cf.Write([]byte(yaml))
+		if err != nil {
+			return err
+		}
+		_, err = wt.Add("config.yaml")
+		if err != nil {
+			return err
+		}
+		author := object.Signature{
+			Name:  "Soft Serve Server",
+			Email: "vt100@charm.sh",
+			When:  time.Now(),
+		}
+		_, err = wt.Commit("Default init", &ggit.CommitOptions{
+			All:       true,
+			Author:    &author,
+			Committer: &author,
+		})
+		if err != nil {
+			return err
+		}
+		err = repo.Push(&ggit.PushOptions{})
+		if err != nil {
+			return err
+		}
+	} else if err != nil {
+		return err
+	}
+	return cfg.Reload()
 }
 
-// WithErrorLogger sets the error logger for the configuration.
-func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
-	c.ErrorLog = logger
-	return c
+func (cfg *Config) isPrivate(repo string) bool {
+	for _, r := range cfg.Repos {
+		if r.Repo == repo {
+			return r.Private
+		}
+	}
+	return false
+}
+
+func templatize(mdt string, tmpl interface{}) (string, error) {
+	t, err := template.New("readme").Parse(mdt)
+	if err != nil {
+		return "", err
+	}
+	buf := &bytes.Buffer{}
+	err = t.Execute(buf, tmpl)
+	if err != nil {
+		return "", err
+	}
+	return buf.String(), nil
 }

config/config_test.go 🔗

@@ -1,19 +1,37 @@
 package config
 
 import (
-	"os"
 	"testing"
 
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/matryer/is"
 )
 
-func TestParseMultipleKeys(t *testing.T) {
+func TestMultipleInitialKeys(t *testing.T) {
+	cfg, err := NewConfig(&config.Config{
+		RepoPath: t.TempDir(),
+		KeyPath:  t.TempDir(),
+		InitialAdminKeys: []string{
+			"testdata/k1.pub",
+			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
+		},
+	})
 	is := is.New(t)
-	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
-	t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
-	cfg := DefaultConfig()
-	is.Equal(cfg.InitialAdminKeys, []string{
-		"testdata/k1.pub",
-		"testdata/k2.pub",
+	is.NoErr(err)
+	err = cfg.Reload()
+	is.NoErr(err)
+	is.Equal(cfg.Users[0].PublicKeys, []string{
+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
+		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
+	}) // should have both keys
+}
+
+func TestEmptyInitialKeys(t *testing.T) {
+	cfg, err := NewConfig(&config.Config{
+		RepoPath: t.TempDir(),
+		KeyPath:  t.TempDir(),
 	})
+	is := is.New(t)
+	is.NoErr(err)
+	is.Equal(len(cfg.Users), 0) // should not have any users
 }

internal/git/git.go → config/git.go 🔗

@@ -1,4 +1,4 @@
-package git
+package config
 
 import (
 	"errors"
@@ -17,13 +17,17 @@ var ErrMissingRepo = errors.New("missing repo")
 
 // Repo represents a Git repository.
 type Repo struct {
-	path       string
-	repository *git.Repository
-	readme     string
-	readmePath string
-	head       *git.Reference
-	refs       []*git.Reference
-	patchCache *lru.Cache
+	name        string
+	description string
+	path        string
+	repository  *git.Repository
+	readme      string
+	readmePath  string
+	head        *git.Reference
+	headCommit  string
+	refs        []*git.Reference
+	patchCache  *lru.Cache
+	private     bool
 }
 
 // open opens a Git repository.
@@ -48,16 +52,34 @@ func (rs *RepoSource) open(path string) (*Repo, error) {
 	return r, nil
 }
 
+// IsPrivate returns true if the repository is private.
+func (r *Repo) IsPrivate() bool {
+	return r.private
+}
+
 // Path returns the path to the repository.
 func (r *Repo) Path() string {
 	return r.path
 }
 
-// GetName returns the name of the repository.
-func (r *Repo) Name() string {
+// Repo returns the repository directory name.
+func (r *Repo) Repo() string {
 	return filepath.Base(r.path)
 }
 
+// Name returns the name of the repository.
+func (r *Repo) Name() string {
+	if r.name == "" {
+		return r.Repo()
+	}
+	return r.name
+}
+
+// Description returns the description for a repository.
+func (r *Repo) Description() string {
+	return r.description
+}
+
 // Readme returns the readme and its path for the repository.
 func (r *Repo) Readme() (readme string, path string) {
 	return r.readme, r.readmePath
@@ -124,6 +146,22 @@ func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
 	return tc, nil
 }
 
+// Commit returns the commit for a given hash.
+func (r *Repo) Commit(hash string) (*git.Commit, error) {
+	if hash == "HEAD" && r.headCommit != "" {
+		hash = r.headCommit
+	}
+	c, err := r.repository.CatFileCommit(hash)
+	if err != nil {
+		return nil, err
+	}
+	r.headCommit = c.ID.String()
+	return &git.Commit{
+		Commit: c,
+		Hash:   git.Hash(c.ID.String()),
+	}, nil
+}
+
 // CommitsByPage returns the commits for a repository.
 func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
 	return r.repository.CommitsByPage(ref, page, size)
@@ -201,6 +239,7 @@ func (rs *RepoSource) LoadRepo(name string) error {
 	rp := filepath.Join(rs.Path, name)
 	r, err := rs.open(rp)
 	if err != nil {
+		log.Printf("error opening repository %s: %s", name, err)
 		return err
 	}
 	rs.repos[name] = r

examples/setuid/main.go 🔗

@@ -18,8 +18,8 @@ import (
 	"syscall"
 	"time"
 
-	"github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/server"
+	"github.com/charmbracelet/soft-serve/server/config"
 )
 
 var (

go.mod 🔗

@@ -6,8 +6,8 @@ require (
 	github.com/alecthomas/chroma v0.10.0
 	github.com/caarlos0/env/v6 v6.9.1
 	github.com/charmbracelet/bubbles v0.11.0
-	github.com/charmbracelet/bubbletea v0.22.0
-	github.com/charmbracelet/glamour v0.4.0
+	github.com/charmbracelet/bubbletea v0.21.0
+	github.com/charmbracelet/glamour v0.5.0
 	github.com/charmbracelet/lipgloss v0.5.0
 	github.com/charmbracelet/wish v0.5.0
 	github.com/dustin/go-humanize v1.0.0
@@ -22,6 +22,7 @@ require (
 )
 
 require (
+	github.com/aymanbagabas/go-osc52 v1.0.3
 	github.com/charmbracelet/keygen v0.3.0
 	github.com/gobwas/glob v0.2.3
 	github.com/gogs/git-module v1.6.1
@@ -68,6 +69,6 @@ require (
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
 	golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
 	golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect
-	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+	golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 )

go.sum 🔗

@@ -17,6 +17,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
+github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
 github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k=
@@ -27,11 +29,10 @@ github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQ
 github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q=
 github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc=
 github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM=
+github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
 github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
-github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
-github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
-github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k=
-github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
+github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
+github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
 github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
 github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y=
 github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM=
@@ -215,8 +216,9 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIj
 golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8=
+golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=

internal/config/config.go 🔗

@@ -1,277 +0,0 @@
-package config
-
-import (
-	"bytes"
-	"errors"
-	"io/fs"
-	"log"
-	"path/filepath"
-	"strings"
-	"sync"
-	"text/template"
-	"time"
-
-	"golang.org/x/crypto/ssh"
-	"gopkg.in/yaml.v3"
-
-	"fmt"
-	"os"
-
-	"github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/internal/git"
-	"github.com/go-git/go-billy/v5/memfs"
-	ggit "github.com/go-git/go-git/v5"
-	"github.com/go-git/go-git/v5/plumbing/object"
-	"github.com/go-git/go-git/v5/plumbing/transport"
-	"github.com/go-git/go-git/v5/storage/memory"
-)
-
-// Config is the Soft Serve configuration.
-type Config struct {
-	Name         string          `yaml:"name"`
-	Host         string          `yaml:"host"`
-	Port         int             `yaml:"port"`
-	AnonAccess   string          `yaml:"anon-access"`
-	AllowKeyless bool            `yaml:"allow-keyless"`
-	Users        []User          `yaml:"users"`
-	Repos        []Repo          `yaml:"repos"`
-	Source       *git.RepoSource `yaml:"-"`
-	Cfg          *config.Config  `yaml:"-"`
-	mtx          sync.Mutex
-}
-
-// User contains user-level configuration for a repository.
-type User struct {
-	Name        string   `yaml:"name"`
-	Admin       bool     `yaml:"admin"`
-	PublicKeys  []string `yaml:"public-keys"`
-	CollabRepos []string `yaml:"collab-repos"`
-}
-
-// Repo contains repository configuration information.
-type Repo struct {
-	Name    string `yaml:"name"`
-	Repo    string `yaml:"repo"`
-	Note    string `yaml:"note"`
-	Private bool   `yaml:"private"`
-	Readme  string `yaml:"readme"`
-}
-
-// NewConfig creates a new internal Config struct.
-func NewConfig(cfg *config.Config) (*Config, error) {
-	var anonAccess string
-	var yamlUsers string
-	var displayHost string
-	host := cfg.Host
-	port := cfg.Port
-
-	pks := make([]string, 0)
-	for _, k := range cfg.InitialAdminKeys {
-		if bts, err := os.ReadFile(k); err == nil {
-			// pk is a file, set its contents as pk
-			k = string(bts)
-		}
-		var pk = strings.TrimSpace(k)
-		if pk == "" {
-			continue
-		}
-		// it is a valid ssh key, nothing to do
-		if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
-			return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
-		}
-		pks = append(pks, pk)
-	}
-
-	rs := git.NewRepoSource(cfg.RepoPath)
-	c := &Config{
-		Cfg: cfg,
-	}
-	c.Host = cfg.Host
-	c.Port = port
-	c.Source = rs
-	if len(pks) == 0 {
-		anonAccess = "read-write"
-	} else {
-		anonAccess = "no-access"
-	}
-	if host == "" {
-		displayHost = "localhost"
-	} else {
-		displayHost = host
-	}
-	yamlConfig := fmt.Sprintf(defaultConfig,
-		displayHost,
-		port,
-		anonAccess,
-		len(pks) == 0,
-	)
-	if len(pks) == 0 {
-		yamlUsers = defaultUserConfig
-	} else {
-		var result string
-		for _, pk := range pks {
-			result += fmt.Sprintf("      - %s\n", pk)
-		}
-		yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
-	}
-	yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
-	err := c.createDefaultConfigRepo(yaml)
-	if err != nil {
-		return nil, err
-	}
-	return c, nil
-}
-
-// Reload reloads the configuration.
-func (cfg *Config) Reload() error {
-	cfg.mtx.Lock()
-	defer cfg.mtx.Unlock()
-	err := cfg.Source.LoadRepos()
-	if err != nil {
-		return err
-	}
-	cr, err := cfg.Source.GetRepo("config")
-	if err != nil {
-		return err
-	}
-	cs, _, err := cr.LatestFile("config.yaml")
-	if err != nil {
-		return err
-	}
-	err = yaml.Unmarshal([]byte(cs), cfg)
-	if err != nil {
-		return fmt.Errorf("bad yaml in config.yaml: %s", err)
-	}
-	for _, r := range cfg.Source.AllRepos() {
-		name := r.Name()
-		err = r.UpdateServerInfo()
-		if err != nil {
-			log.Printf("error updating server info for %s: %s", name, err)
-		}
-		pat := "README*"
-		rp := ""
-		for _, rr := range cfg.Repos {
-			if name == rr.Repo {
-				rp = rr.Readme
-				break
-			}
-		}
-		if rp != "" {
-			pat = rp
-		}
-		rm := ""
-		fc, fp, _ := r.LatestFile(pat)
-		rm = fc
-		if name == "config" {
-			md, err := templatize(rm, cfg)
-			if err != nil {
-				return err
-			}
-			rm = md
-		}
-		r.SetReadme(rm, fp)
-	}
-	return nil
-}
-
-func createFile(path string, content string) error {
-	f, err := os.Create(path)
-	if err != nil {
-		return err
-	}
-	defer f.Close()
-	_, err = f.WriteString(content)
-	if err != nil {
-		return err
-	}
-	return f.Sync()
-}
-
-func (cfg *Config) createDefaultConfigRepo(yaml string) error {
-	cn := "config"
-	rp := filepath.Join(cfg.Cfg.RepoPath, cn)
-	rs := cfg.Source
-	err := rs.LoadRepo(cn)
-	if errors.Is(err, fs.ErrNotExist) {
-		log.Printf("creating default config repo %s", cn)
-		repo, err := ggit.PlainInit(rp, true)
-		if err != nil {
-			return err
-		}
-		repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
-			URL: rp,
-		})
-		if err != nil && err != transport.ErrEmptyRemoteRepository {
-			return err
-		}
-		wt, err := repo.Worktree()
-		if err != nil {
-			return err
-		}
-		rm, err := wt.Filesystem.Create("README.md")
-		if err != nil {
-			return err
-		}
-		_, err = rm.Write([]byte(defaultReadme))
-		if err != nil {
-			return err
-		}
-		_, err = wt.Add("README.md")
-		if err != nil {
-			return err
-		}
-		cf, err := wt.Filesystem.Create("config.yaml")
-		if err != nil {
-			return err
-		}
-		_, err = cf.Write([]byte(yaml))
-		if err != nil {
-			return err
-		}
-		_, err = wt.Add("config.yaml")
-		if err != nil {
-			return err
-		}
-		author := &object.Signature{
-			Name:  "Soft Serve Server",
-			Email: "vt100@charm.sh",
-			When:  time.Now(),
-		}
-		_, err = wt.Commit("Default init", &ggit.CommitOptions{
-			All:    true,
-			Author: author,
-		})
-		if err != nil {
-			return err
-		}
-		err = repo.Push(&ggit.PushOptions{})
-		if err != nil {
-			return err
-		}
-	} else if err != nil {
-		return err
-	}
-	return cfg.Reload()
-}
-
-func (cfg *Config) isPrivate(repo string) bool {
-	for _, r := range cfg.Repos {
-		if r.Repo == repo {
-			return r.Private
-		}
-	}
-	return false
-}
-
-func templatize(mdt string, tmpl interface{}) (string, error) {
-	t, err := template.New("readme").Parse(mdt)
-	if err != nil {
-		return "", err
-	}
-	buf := &bytes.Buffer{}
-	err = t.Execute(buf, tmpl)
-	if err != nil {
-		return "", err
-	}
-	return buf.String(), nil
-}

internal/config/config_test.go 🔗

@@ -1,37 +0,0 @@
-package config
-
-import (
-	"testing"
-
-	"github.com/charmbracelet/soft-serve/config"
-	"github.com/matryer/is"
-)
-
-func TestMultipleInitialKeys(t *testing.T) {
-	cfg, err := NewConfig(&config.Config{
-		RepoPath: t.TempDir(),
-		KeyPath:  t.TempDir(),
-		InitialAdminKeys: []string{
-			"testdata/k1.pub",
-			"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
-		},
-	})
-	is := is.New(t)
-	is.NoErr(err)
-	err = cfg.Reload()
-	is.NoErr(err)
-	is.Equal(cfg.Users[0].PublicKeys, []string{
-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
-		"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
-	}) // should have both keys
-}
-
-func TestEmptyInitialKeys(t *testing.T) {
-	cfg, err := NewConfig(&config.Config{
-		RepoPath: t.TempDir(),
-		KeyPath:  t.TempDir(),
-	})
-	is := is.New(t)
-	is.NoErr(err)
-	is.Equal(len(cfg.Users), 0) // should not have any users
-}

internal/tui/bubble.go 🔗

@@ -1,234 +0,0 @@
-package tui
-
-import (
-	"fmt"
-	"strings"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/config"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/gliderlabs/ssh"
-)
-
-const (
-	repoNameMaxWidth = 32
-)
-
-type sessionState int
-
-const (
-	startState sessionState = iota
-	errorState
-	loadedState
-	quittingState
-	quitState
-)
-
-type SessionConfig struct {
-	Width       int
-	Height      int
-	InitialRepo string
-	Session     ssh.Session
-}
-
-type MenuEntry struct {
-	Name   string `json:"name"`
-	Note   string `json:"note"`
-	Repo   string `json:"repo"`
-	bubble *repo.Bubble
-}
-
-type Bubble struct {
-	config      *config.Config
-	styles      *style.Styles
-	state       sessionState
-	error       string
-	width       int
-	height      int
-	initialRepo string
-	repoMenu    []MenuEntry
-	boxes       []tea.Model
-	activeBox   int
-	repoSelect  *selection.Bubble
-	session     ssh.Session
-
-	// remember the last resize so we can re-send it when selecting a different repo.
-	lastResize tea.WindowSizeMsg
-}
-
-func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
-	b := &Bubble{
-		config:      cfg,
-		styles:      style.DefaultStyles(),
-		width:       sCfg.Width,
-		height:      sCfg.Height,
-		repoMenu:    make([]MenuEntry, 0),
-		boxes:       make([]tea.Model, 2),
-		initialRepo: sCfg.InitialRepo,
-		session:     sCfg.Session,
-	}
-	b.state = startState
-	return b
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return b.setupCmd
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "q", "ctrl+c":
-			return b, tea.Quit
-		case "tab", "shift+tab":
-			b.activeBox = (b.activeBox + 1) % 2
-		}
-	case errMsg:
-		b.error = msg.Error()
-		b.state = errorState
-		return b, nil
-	case tea.WindowSizeMsg:
-		b.lastResize = msg
-		b.width = msg.Width
-		b.height = msg.Height
-		if b.state == loadedState {
-			for i, bx := range b.boxes {
-				m, cmd := bx.Update(msg)
-				b.boxes[i] = m
-				if cmd != nil {
-					cmds = append(cmds, cmd)
-				}
-			}
-		}
-	case selection.SelectedMsg:
-		b.activeBox = 1
-		rb := b.repoMenu[msg.Index].bubble
-		b.boxes[1] = rb
-	case selection.ActiveMsg:
-		b.boxes[1] = b.repoMenu[msg.Index].bubble
-		cmds = append(cmds, func() tea.Msg {
-			return b.lastResize
-		})
-	}
-	if b.state == loadedState {
-		ab, cmd := b.boxes[b.activeBox].Update(msg)
-		b.boxes[b.activeBox] = ab
-		if cmd != nil {
-			cmds = append(cmds, cmd)
-		}
-	}
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) viewForBox(i int) string {
-	isActive := i == b.activeBox
-	switch box := b.boxes[i].(type) {
-	case *selection.Bubble:
-		// Menu
-		var s lipgloss.Style
-		s = b.styles.Menu
-		if isActive {
-			s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
-		}
-		return s.Render(box.View())
-	case *repo.Bubble:
-		// Repo details
-		box.Active = isActive
-		return box.View()
-	default:
-		panic(fmt.Sprintf("unknown box type %T", box))
-	}
-}
-
-func (b Bubble) headerView() string {
-	w := b.width - b.styles.App.GetHorizontalFrameSize()
-	name := ""
-	if b.config != nil {
-		name = b.config.Name
-	}
-	return b.styles.Header.Copy().Width(w).Render(name)
-}
-
-func (b Bubble) footerView() string {
-	w := &strings.Builder{}
-	var h []common.HelpEntry
-	if b.state != errorState {
-		h = []common.HelpEntry{
-			{Key: "tab", Value: "section"},
-		}
-		if box, ok := b.boxes[b.activeBox].(common.BubbleHelper); ok {
-			help := box.Help()
-			for _, he := range help {
-				h = append(h, he)
-			}
-		}
-	}
-	h = append(h, common.HelpEntry{Key: "q", Value: "quit"})
-	for i, v := range h {
-		fmt.Fprint(w, helpEntryRender(v, b.styles))
-		if i != len(h)-1 {
-			fmt.Fprint(w, b.styles.HelpDivider)
-		}
-	}
-	branch := ""
-	if b.state == loadedState {
-		ref := b.boxes[1].(*repo.Bubble).Reference()
-		branch = ref.Name().Short()
-	}
-	help := w.String()
-	branchMaxWidth := b.width - // bubble width
-		lipgloss.Width(help) - // help width
-		b.styles.App.GetHorizontalFrameSize() // App paddings
-	branch = b.styles.Branch.Render(common.TruncateString(branch, branchMaxWidth-1, "…"))
-	gap := lipgloss.NewStyle().
-		Width(b.width -
-			lipgloss.Width(help) -
-			lipgloss.Width(branch) -
-			b.styles.App.GetHorizontalFrameSize()).
-		Render("")
-	footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch)
-	return b.styles.Footer.Render(footer)
-}
-
-func (b Bubble) errorView() string {
-	s := b.styles
-	str := lipgloss.JoinHorizontal(
-		lipgloss.Top,
-		s.ErrorTitle.Render("Bummer"),
-		s.ErrorBody.Render(b.error),
-	)
-	h := b.height -
-		s.App.GetVerticalFrameSize() -
-		lipgloss.Height(b.headerView()) -
-		lipgloss.Height(b.footerView()) -
-		s.RepoBody.GetVerticalFrameSize() +
-		3 // TODO: this is repo header height -- get it dynamically
-	return s.Error.Copy().Height(h).Render(str)
-}
-
-func (b Bubble) View() string {
-	s := strings.Builder{}
-	s.WriteString(b.headerView())
-	s.WriteRune('\n')
-	switch b.state {
-	case loadedState:
-		lb := b.viewForBox(0)
-		rb := b.viewForBox(1)
-		s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
-	case errorState:
-		s.WriteString(b.errorView())
-	}
-	s.WriteRune('\n')
-	s.WriteString(b.footerView())
-	return b.styles.App.Render(s.String())
-}
-
-func helpEntryRender(h common.HelpEntry, s *style.Styles) string {
-	return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
-}

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

@@ -1,137 +0,0 @@
-package repo
-
-import (
-	"fmt"
-	"strconv"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	gitui "github.com/charmbracelet/soft-serve/tui"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/muesli/reflow/truncate"
-	"github.com/muesli/reflow/wrap"
-)
-
-const (
-	repoNameMaxWidth = 32
-)
-
-type Bubble struct {
-	name         string
-	host         string
-	port         int
-	repo         common.GitRepo
-	styles       *style.Styles
-	width        int
-	widthMargin  int
-	height       int
-	heightMargin int
-	box          *gitui.Bubble
-
-	Active bool
-}
-
-func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
-	b := &Bubble{
-		name:         repo.Name(),
-		host:         host,
-		port:         port,
-		width:        width,
-		widthMargin:  wm,
-		height:       height,
-		heightMargin: hm,
-		styles:       styles,
-	}
-	b.repo = repo
-	b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize())
-	return b
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return b.box.Init()
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		if msg.Width == b.width && msg.Height == b.height {
-			return b, nil
-		}
-		b.width = msg.Width
-		b.height = msg.Height
-	}
-	box, cmd := b.box.Update(msg)
-	b.box = box.(*gitui.Bubble)
-	return b, cmd
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return b.box.Help()
-}
-
-func (b Bubble) headerView() string {
-	// Render repo title
-	title := b.name
-	if title == "config" {
-		title = "Home"
-	}
-	title = truncate.StringWithTail(title, repoNameMaxWidth, "…")
-	title = b.styles.RepoTitle.Render(title)
-
-	// Render clone command
-	var note string
-	if b.name == "config" {
-		note = ""
-	} else {
-		note = fmt.Sprintf("git clone %s", b.sshAddress())
-	}
-	noteWidth := b.width -
-		b.widthMargin -
-		lipgloss.Width(title) -
-		b.styles.RepoTitleBox.GetHorizontalFrameSize()
-	// Hard-wrap the clone command only, without the usual word-wrapping. since
-	// a long repo name isn't going to be a series of space-separated "words",
-	// we'll always want it to be perfectly hard-wrapped.
-	note = wrap.String(note, noteWidth-b.styles.RepoNote.GetHorizontalFrameSize())
-	note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
-
-	// Render borders on name and command
-	height := common.Max(lipgloss.Height(title), lipgloss.Height(note))
-	titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
-	noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
-	if b.Active {
-		titleBoxStyle = titleBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
-		noteBoxStyle = noteBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
-	}
-	title = titleBoxStyle.Render(title)
-	note = noteBoxStyle.Render(note)
-
-	// Render
-	return lipgloss.JoinHorizontal(lipgloss.Top, title, note)
-}
-
-func (b *Bubble) View() string {
-	header := b.headerView()
-	bs := b.styles.RepoBody.Copy()
-	if b.Active {
-		bs = bs.BorderForeground(b.styles.ActiveBorderColor)
-	}
-	body := bs.Width(b.width - b.widthMargin - b.styles.RepoBody.GetVerticalFrameSize()).
-		Height(b.height - b.heightMargin - lipgloss.Height(header)).
-		Render(b.box.View())
-	return header + body
-}
-
-func (b *Bubble) Reference() *git.Reference {
-	return b.box.Reference()
-}
-
-func (b Bubble) sshAddress() string {
-	p := ":" + strconv.Itoa(int(b.port))
-	if p == ":22" {
-		p = ""
-	}
-	return fmt.Sprintf("ssh://%s%s/%s", b.host, p, b.name)
-}

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

@@ -1,107 +0,0 @@
-package selection
-
-import (
-	"strings"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/muesli/reflow/truncate"
-)
-
-type SelectedMsg struct {
-	Name  string
-	Index int
-}
-
-type ActiveMsg struct {
-	Name  string
-	Index int
-}
-
-type Bubble struct {
-	Items        []string
-	SelectedItem int
-	styles       *style.Styles
-}
-
-func NewBubble(items []string, styles *style.Styles) *Bubble {
-	return &Bubble{
-		Items:  items,
-		styles: styles,
-	}
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return nil
-}
-
-func (b Bubble) View() string {
-	s := strings.Builder{}
-	repoNameMaxWidth := b.styles.Menu.GetWidth() - // menu width
-		b.styles.Menu.GetHorizontalPadding() - // menu padding
-		lipgloss.Width(b.styles.MenuCursor.String()) - // cursor
-		b.styles.MenuItem.GetHorizontalFrameSize() // menu item gaps
-	for i, item := range b.Items {
-		item := truncate.StringWithTail(item, uint(repoNameMaxWidth), "…")
-		if i == b.SelectedItem {
-			s.WriteString(b.styles.MenuCursor.String())
-			s.WriteString(b.styles.SelectedMenuItem.Render(item))
-		} else {
-			s.WriteString(b.styles.MenuItem.Render(item))
-		}
-		if i < len(b.Items)-1 {
-			s.WriteRune('\n')
-		}
-	}
-	return s.String()
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "k", "up":
-			if b.SelectedItem > 0 {
-				b.SelectedItem--
-				cmds = append(cmds, b.sendActiveMessage)
-			}
-		case "j", "down":
-			if b.SelectedItem < len(b.Items)-1 {
-				b.SelectedItem++
-				cmds = append(cmds, b.sendActiveMessage)
-			}
-		case "enter":
-			cmds = append(cmds, b.sendSelectedMessage)
-		}
-	}
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return []common.HelpEntry{
-		{Key: "↑/↓", Value: "navigate"},
-	}
-}
-
-func (b *Bubble) sendActiveMessage() tea.Msg {
-	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
-		return ActiveMsg{
-			Name:  b.Items[b.SelectedItem],
-			Index: b.SelectedItem,
-		}
-	}
-	return nil
-}
-
-func (b *Bubble) sendSelectedMessage() tea.Msg {
-	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
-		return SelectedMsg{
-			Name:  b.Items[b.SelectedItem],
-			Index: b.SelectedItem,
-		}
-	}
-	return nil
-}

internal/tui/commands.go 🔗

@@ -1,121 +0,0 @@
-package tui
-
-import (
-	"fmt"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
-	"github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	gm "github.com/charmbracelet/wish/git"
-)
-
-type errMsg struct{ err error }
-
-func (e errMsg) Error() string {
-	return e.err.Error()
-}
-
-func (b *Bubble) setupCmd() tea.Msg {
-	if b.config == nil || b.config.Source == nil {
-		return errMsg{err: fmt.Errorf("config not set")}
-	}
-	mes, err := b.menuEntriesFromSource()
-	if err != nil {
-		return errMsg{err}
-	}
-	if len(mes) == 0 {
-		return errMsg{fmt.Errorf("no repos found")}
-	}
-	b.repoMenu = mes
-	rs := make([]string, 0)
-	for _, m := range mes {
-		rs = append(rs, m.Name)
-	}
-	b.repoSelect = selection.NewBubble(rs, b.styles)
-	b.boxes[0] = b.repoSelect
-
-	// Jump to an initial repo
-	ir := -1
-	if b.initialRepo != "" {
-		for i, me := range b.repoMenu {
-			if me.Repo == b.initialRepo {
-				ir = i
-			}
-		}
-	}
-	if ir == -1 {
-		b.boxes[1] = b.repoMenu[0].bubble
-		b.activeBox = 0
-	} else {
-		b.boxes[1] = b.repoMenu[ir].bubble
-		b.repoSelect.SelectedItem = ir
-		b.activeBox = 1
-	}
-
-	b.state = loadedState
-	return nil
-}
-
-func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) {
-	mes := make([]MenuEntry, 0)
-	for _, cr := range b.config.Repos {
-		acc := b.config.AuthRepo(cr.Repo, b.session.PublicKey())
-		if acc == gm.NoAccess && cr.Repo != "config" {
-			continue
-		}
-		if cr.Private && acc < gm.ReadOnlyAccess {
-			continue
-		}
-		me, err := b.newMenuEntry(cr.Name, cr.Repo)
-		if err != nil {
-			return nil, err
-		}
-		mes = append(mes, me)
-	}
-	for _, r := range b.config.Source.AllRepos() {
-		var found bool
-		rn := r.Name()
-		for _, me := range mes {
-			if me.Repo == rn {
-				found = true
-			}
-		}
-		if !found {
-			acc := b.config.AuthRepo(rn, b.session.PublicKey())
-			if acc == gm.NoAccess {
-				continue
-			}
-			me, err := b.newMenuEntry(rn, rn)
-			if err != nil {
-				return nil, err
-			}
-			mes = append(mes, me)
-		}
-	}
-	return mes, nil
-}
-
-func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) {
-	me := MenuEntry{Name: name, Repo: rn}
-	r, err := b.config.Source.GetRepo(rn)
-	if err != nil {
-		return me, err
-	}
-	boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize()
-	// TODO: also send this along with a tea.WindowSizeMsg
-	var heightMargin = lipgloss.Height(b.headerView()) +
-		lipgloss.Height(b.footerView()) +
-		b.styles.RepoBody.GetVerticalFrameSize() +
-		b.styles.App.GetVerticalMargins()
-	rb := repo.NewBubble(r, b.config.Host, b.config.Port, b.styles, b.width, boxLeftWidth, b.height, heightMargin)
-	initCmd := rb.Init()
-	msg := initCmd()
-	switch msg := msg.(type) {
-	case common.ErrMsg:
-		return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error())
-	}
-	me.bubble = rb
-	return me, nil
-}

internal/tui/session.go 🔗

@@ -1,37 +0,0 @@
-package tui
-
-import (
-	"fmt"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/soft-serve/internal/config"
-	"github.com/gliderlabs/ssh"
-)
-
-// SessionHandler handles the bubble tea session.
-func SessionHandler(cfg *config.Config) func(ssh.Session) (tea.Model, []tea.ProgramOption) {
-	return func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
-		pty, _, active := s.Pty()
-		if !active {
-			fmt.Println("not active")
-			return nil, nil
-		}
-		cmd := s.Command()
-		scfg := &SessionConfig{Session: s}
-		switch len(cmd) {
-		case 0:
-			scfg.InitialRepo = ""
-		case 1:
-			scfg.InitialRepo = cmd[0]
-		}
-		scfg.Width = pty.Window.Width
-		scfg.Height = pty.Window.Height
-		if cfg.Cfg.Callbacks != nil {
-			cfg.Cfg.Callbacks.Tui("view")
-		}
-		return NewBubble(cfg, scfg), []tea.ProgramOption{
-			tea.WithAltScreen(),
-			tea.WithoutCatchPanics(),
-		}
-	}
-}

server/cmd/cat.go 🔗

@@ -7,8 +7,8 @@ import (
 	"github.com/alecthomas/chroma/lexers"
 	gansi "github.com/charmbracelet/glamour/ansi"
 	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/git"
-	"github.com/charmbracelet/soft-serve/tui/common"
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui/common"
 	gitwish "github.com/charmbracelet/wish/git"
 	"github.com/muesli/termenv"
 	"github.com/spf13/cobra"
@@ -40,10 +40,10 @@ func CatCommand() *cobra.Command {
 			if auth < gitwish.ReadOnlyAccess {
 				return ErrUnauthorized
 			}
-			var repo *git.Repo
+			var repo *config.Repo
 			repoExists := false
 			for _, rp := range ac.Source.AllRepos() {
-				if rp.Name() == rn {
+				if rp.Repo() == rn {
 					repoExists = true
 					repo = rp
 					break
@@ -109,7 +109,7 @@ func withFormatting(p, c string) (string, error) {
 		Language: lang,
 	}
 	r := strings.Builder{}
-	styles := common.DefaultStyles()
+	styles := common.StyleConfig()
 	styles.CodeBlock.Margin = &zero
 	rctx := gansi.NewRenderContext(gansi.Options{
 		Styles:       styles,

server/cmd/cmd.go 🔗

@@ -3,11 +3,26 @@ package cmd
 import (
 	"fmt"
 
-	appCfg "github.com/charmbracelet/soft-serve/internal/config"
+	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/gliderlabs/ssh"
 	"github.com/spf13/cobra"
 )
 
+// ContextKey is a type that can be used as a key in a context.
+type ContextKey string
+
+// String returns the string representation of the ContextKey.
+func (c ContextKey) String() string {
+	return "soft-serve cli context key " + string(c)
+}
+
+var (
+	// ConfigCtxKey is the key for the config in the context.
+	ConfigCtxKey = ContextKey("config")
+	// SessionCtxKey is the key for the session in the context.
+	SessionCtxKey = ContextKey("session")
+)
+
 var (
 	// ErrUnauthorized is returned when the user is not authorized to perform action.
 	ErrUnauthorized = fmt.Errorf("Unauthorized")
@@ -64,7 +79,7 @@ func RootCommand() *cobra.Command {
 
 func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) {
 	ctx := cmd.Context()
-	ac := ctx.Value("config").(*appCfg.Config)
-	s := ctx.Value("session").(ssh.Session)
+	ac := ctx.Value(ConfigCtxKey).(*appCfg.Config)
+	s := ctx.Value(SessionCtxKey).(ssh.Session)
 	return ac, s
 }

server/cmd/git.go 🔗

@@ -4,7 +4,7 @@ import (
 	"io"
 	"os/exec"
 
-	"github.com/charmbracelet/soft-serve/internal/git"
+	"github.com/charmbracelet/soft-serve/config"
 	gitwish "github.com/charmbracelet/wish/git"
 	"github.com/spf13/cobra"
 )
@@ -23,11 +23,11 @@ func GitCommand() *cobra.Command {
 			if len(args) < 1 {
 				return runGit(nil, s, s, "")
 			}
-			var repo *git.Repo
+			var repo *config.Repo
 			rn := args[0]
 			repoExists := false
 			for _, rp := range ac.Source.AllRepos() {
-				if rp.Name() == rn {
+				if rp.Repo() == rn {
 					repoExists = true
 					repo = rp
 					break

server/cmd/list.go 🔗

@@ -33,7 +33,7 @@ func ListCommand() *cobra.Command {
 			}
 			if path == "" || path == "." || path == "/" {
 				for _, r := range ac.Source.AllRepos() {
-					fmt.Fprintln(s, r.Name())
+					fmt.Fprintln(s, r.Repo())
 				}
 				return nil
 			}

server/config/config.go 🔗

@@ -0,0 +1,53 @@
+package config
+
+import (
+	"log"
+	"path/filepath"
+
+	"github.com/caarlos0/env/v6"
+)
+
+// Callbacks provides an interface that can be used to run callbacks on different events.
+type Callbacks interface {
+	Tui(action string)
+	Push(repo string)
+	Fetch(repo string)
+}
+
+// Config is the configuration for Soft Serve.
+type Config struct {
+	BindAddr         string   `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
+	Host             string   `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
+	Port             int      `env:"SOFT_SERVE_PORT" envDefault:"23231"`
+	KeyPath          string   `env:"SOFT_SERVE_KEY_PATH"`
+	RepoPath         string   `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
+	InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
+	Callbacks        Callbacks
+	ErrorLog         *log.Logger
+}
+
+// DefaultConfig returns a Config with the values populated with the defaults
+// or specified environment variables.
+func DefaultConfig() *Config {
+	cfg := &Config{ErrorLog: log.Default()}
+	if err := env.Parse(cfg); err != nil {
+		log.Fatalln(err)
+	}
+	if cfg.KeyPath == "" {
+		// NB: cross-platform-compatible path
+		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
+	}
+	return cfg.WithCallbacks(nil)
+}
+
+// WithCallbacks applies the given Callbacks to the configuration.
+func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
+	c.Callbacks = callbacks
+	return c
+}
+
+// WithErrorLogger sets the error logger for the configuration.
+func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
+	c.ErrorLog = logger
+	return c
+}

server/config/config_test.go 🔗

@@ -0,0 +1,19 @@
+package config
+
+import (
+	"os"
+	"testing"
+
+	"github.com/matryer/is"
+)
+
+func TestParseMultipleKeys(t *testing.T) {
+	is := is.New(t)
+	is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
+	t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
+	cfg := DefaultConfig()
+	is.Equal(cfg.InitialAdminKeys, []string{
+		"testdata/k1.pub",
+		"testdata/k2.pub",
+	})
+}

server/middleware.go 🔗

@@ -4,7 +4,7 @@ import (
 	"context"
 	"fmt"
 
-	appCfg "github.com/charmbracelet/soft-serve/internal/config"
+	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/server/cmd"
 	"github.com/charmbracelet/wish"
 	"github.com/gliderlabs/ssh"
@@ -19,8 +19,8 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware {
 				if active {
 					return
 				}
-				ctx := context.WithValue(s.Context(), "config", ac) //nolint:revive
-				ctx = context.WithValue(ctx, "session", s)          //nolint:revive
+				ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac)
+				ctx = context.WithValue(ctx, cmd.SessionCtxKey, s)
 
 				use := "ssh"
 				port := ac.Port

server/middleware_test.go 🔗

@@ -4,8 +4,8 @@ import (
 	"os"
 	"testing"
 
-	sconfig "github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/internal/config"
+	"github.com/charmbracelet/soft-serve/config"
+	sconfig "github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/wish/testsession"
 	"github.com/gliderlabs/ssh"
 	"github.com/matryer/is"

server/server.go 🔗

@@ -6,15 +6,15 @@ import (
 	"log"
 	"net"
 
-	"github.com/charmbracelet/soft-serve/config"
-	appCfg "github.com/charmbracelet/soft-serve/internal/config"
-	"github.com/charmbracelet/soft-serve/internal/tui"
+	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/wish"
 	bm "github.com/charmbracelet/wish/bubbletea"
 	gm "github.com/charmbracelet/wish/git"
 	lm "github.com/charmbracelet/wish/logging"
 	rm "github.com/charmbracelet/wish/recover"
 	"github.com/gliderlabs/ssh"
+	"github.com/muesli/termenv"
 )
 
 // Server is the Soft Serve server.
@@ -37,10 +37,10 @@ func NewServer(cfg *config.Config) *Server {
 	mw := []wish.Middleware{
 		rm.MiddlewareWithLogger(
 			cfg.ErrorLog,
-			lm.Middleware(),
 			softMiddleware(ac),
-			bm.Middleware(tui.SessionHandler(ac)),
+			bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
 			gm.Middleware(cfg.RepoPath, ac),
+			lm.Middleware(),
 		),
 	}
 	s, err := wish.NewServer(

server/server_test.go 🔗

@@ -7,7 +7,7 @@ import (
 	"testing"
 
 	"github.com/charmbracelet/keygen"
-	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/gliderlabs/ssh"
 	"github.com/go-git/go-git/v5"
 	gconfig "github.com/go-git/go-git/v5/config"

server/session.go 🔗

@@ -0,0 +1,57 @@
+package server
+
+import (
+	"fmt"
+
+	"github.com/aymanbagabas/go-osc52"
+	tea "github.com/charmbracelet/bubbletea"
+	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/keymap"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+	bm "github.com/charmbracelet/wish/bubbletea"
+	"github.com/gliderlabs/ssh"
+)
+
+// SessionHandler is the soft-serve bubbletea ssh session handler.
+func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
+	return func(s ssh.Session) *tea.Program {
+		pty, _, active := s.Pty()
+		if !active {
+			return nil
+		}
+		cmd := s.Command()
+		initialRepo := ""
+		if len(cmd) == 1 {
+			initialRepo = cmd[0]
+		}
+		if ac.Cfg.Callbacks != nil {
+			ac.Cfg.Callbacks.Tui("new session")
+		}
+		envs := s.Environ()
+		envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term))
+		output := osc52.NewOutput(s, envs)
+		c := common.Common{
+			Copy:   output,
+			Styles: styles.DefaultStyles(),
+			KeyMap: keymap.DefaultKeyMap(),
+			Width:  pty.Window.Width,
+			Height: pty.Window.Height,
+		}
+		m := ui.New(
+			ac,
+			s,
+			c,
+			initialRepo,
+		)
+		p := tea.NewProgram(m,
+			tea.WithInput(s),
+			tea.WithOutput(s),
+			tea.WithAltScreen(),
+			tea.WithoutCatchPanics(),
+			tea.WithMouseCellMotion(),
+		)
+		return p
+	}
+}

tui/about/bubble.go 🔗

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

tui/bubble.go 🔗

@@ -1,155 +0,0 @@
-package tui
-
-import (
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/about"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/log"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	"github.com/charmbracelet/soft-serve/tui/tree"
-)
-
-const (
-	repoNameMaxWidth = 32
-)
-
-type state int
-
-const (
-	aboutState state = iota
-	refsState
-	logState
-	treeState
-)
-
-type Bubble struct {
-	state        state
-	repo         common.GitRepo
-	height       int
-	heightMargin int
-	width        int
-	widthMargin  int
-	style        *style.Styles
-	boxes        []tea.Model
-	ref          *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble {
-	b := &Bubble{
-		repo:         repo,
-		state:        aboutState,
-		width:        width,
-		widthMargin:  wm,
-		height:       height,
-		heightMargin: hm,
-		style:        styles,
-		boxes:        make([]tea.Model, 4),
-	}
-	heightMargin := hm + lipgloss.Height(b.headerView())
-	b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
-	b.boxes[refsState] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin)
-	b.boxes[logState] = log.NewBubble(repo, b.style, width, wm, height, heightMargin)
-	b.boxes[treeState] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin)
-	return b
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return b.setupCmd
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		if b.repo.Name() != "config" {
-			switch msg.String() {
-			case "R":
-				b.state = aboutState
-			case "B":
-				b.state = refsState
-			case "C":
-				b.state = logState
-			case "F":
-				b.state = treeState
-			}
-		}
-	case tea.WindowSizeMsg:
-		b.width = msg.Width
-		b.height = msg.Height
-		for i, bx := range b.boxes {
-			m, cmd := bx.Update(msg)
-			b.boxes[i] = m
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	case refs.RefMsg:
-		b.state = treeState
-		b.ref = msg
-		for i, bx := range b.boxes {
-			m, cmd := bx.Update(msg)
-			b.boxes[i] = m
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	}
-	m, cmd := b.boxes[b.state].Update(msg)
-	b.boxes[b.state] = m
-	if cmd != nil {
-		cmds = append(cmds, cmd)
-	}
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	h := []common.HelpEntry{}
-	h = append(h, b.boxes[b.state].(common.BubbleHelper).Help()...)
-	if b.repo.Name() != "config" {
-		h = append(h, common.HelpEntry{Key: "R", Value: "readme"})
-		h = append(h, common.HelpEntry{Key: "F", Value: "files"})
-		h = append(h, common.HelpEntry{Key: "C", Value: "commits"})
-		h = append(h, common.HelpEntry{Key: "B", Value: "branches"})
-	}
-	return h
-}
-
-func (b *Bubble) Reference() *git.Reference {
-	return b.ref
-}
-
-func (b *Bubble) headerView() string {
-	// TODO better header, tabs?
-	return ""
-}
-
-func (b *Bubble) View() string {
-	header := b.headerView()
-	return header + b.boxes[b.state].View()
-}
-
-func (b *Bubble) setupCmd() tea.Msg {
-	head, err := b.repo.HEAD()
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	b.ref = head
-	cmds := make([]tea.Cmd, 0)
-	for _, bx := range b.boxes {
-		if bx != nil {
-			initCmd := bx.Init()
-			if initCmd != nil {
-				msg := initCmd()
-				switch msg := msg.(type) {
-				case common.ErrMsg:
-					return msg
-				}
-			}
-			cmds = append(cmds, initCmd)
-		}
-	}
-	return tea.Batch(cmds...)
-}

tui/common/consts.go 🔗

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

tui/common/error.go 🔗

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

tui/common/formatter.go 🔗

@@ -1,88 +0,0 @@
-package common
-
-import (
-	"strings"
-
-	"github.com/alecthomas/chroma/lexers"
-	"github.com/charmbracelet/glamour"
-	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/muesli/termenv"
-)
-
-var (
-	RenderCtx = DefaultRenderCtx()
-	Styles    = DefaultStyles()
-)
-
-func DefaultStyles() gansi.StyleConfig {
-	noColor := ""
-	s := glamour.DarkStyleConfig
-	s.Document.StylePrimitive.Color = &noColor
-	s.CodeBlock.Chroma.Text.Color = &noColor
-	s.CodeBlock.Chroma.Name.Color = &noColor
-	return s
-}
-
-func DefaultRenderCtx() gansi.RenderContext {
-	return gansi.NewRenderContext(gansi.Options{
-		ColorProfile: termenv.TrueColor,
-		Styles:       DefaultStyles(),
-	})
-}
-
-func NewRenderCtx(worldwrap int) gansi.RenderContext {
-	return gansi.NewRenderContext(gansi.Options{
-		ColorProfile: termenv.TrueColor,
-		Styles:       DefaultStyles(),
-		WordWrap:     worldwrap,
-	})
-}
-
-func Glamourize(w int, md string) (string, error) {
-	if w > GlamourMaxWidth {
-		w = GlamourMaxWidth
-	}
-	tr, err := glamour.NewTermRenderer(
-		glamour.WithStyles(DefaultStyles()),
-		glamour.WithWordWrap(w),
-	)
-
-	if err != nil {
-		return "", err
-	}
-	mdt, err := tr.Render(md)
-	if err != nil {
-		return "", err
-	}
-	return mdt, nil
-}
-
-func RenderFile(path, content string, width int) (string, error) {
-	lexer := lexers.Fallback
-	if path == "" {
-		lexer = lexers.Analyse(content)
-	} else {
-		lexer = lexers.Match(path)
-	}
-	lang := ""
-	if lexer != nil && lexer.Config() != nil {
-		lang = lexer.Config().Name
-	}
-	formatter := &gansi.CodeBlockElement{
-		Code:     content,
-		Language: lang,
-	}
-	if lang == "markdown" {
-		md, err := Glamourize(width, content)
-		if err != nil {
-			return "", err
-		}
-		return md, nil
-	}
-	r := strings.Builder{}
-	err := formatter.Render(&r, RenderCtx)
-	if err != nil {
-		return "", err
-	}
-	return r.String(), nil
-}

tui/common/git.go 🔗

@@ -1,16 +0,0 @@
-package common
-
-import (
-	"github.com/charmbracelet/soft-serve/git"
-)
-
-type GitRepo interface {
-	Name() string
-	Readme() (string, string)
-	HEAD() (*git.Reference, error)
-	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
-	CountCommits(*git.Reference) (int64, error)
-	Diff(*git.Commit) (*git.Diff, error)
-	References() ([]*git.Reference, error)
-	Tree(*git.Reference, string) (*git.Tree, error)
-}

tui/common/help.go 🔗

@@ -1,10 +0,0 @@
-package common
-
-type BubbleHelper interface {
-	Help() []HelpEntry
-}
-
-type HelpEntry struct {
-	Key   string
-	Value string
-}

tui/common/reset.go 🔗

@@ -1,7 +0,0 @@
-package common
-
-import tea "github.com/charmbracelet/bubbletea"
-
-type BubbleReset interface {
-	Reset() tea.Msg
-}

tui/common/utils.go 🔗

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

tui/log/bubble.go 🔗

@@ -1,383 +0,0 @@
-package log
-
-import (
-	"fmt"
-	"io"
-	"strings"
-	"time"
-
-	"github.com/charmbracelet/bubbles/list"
-	"github.com/charmbracelet/bubbles/spinner"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	gansi "github.com/charmbracelet/glamour/ansi"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
-)
-
-var (
-	diffChroma = &gansi.CodeBlockElement{
-		Code:     "",
-		Language: "diff",
-	}
-	waitBeforeLoading = time.Millisecond * 300
-)
-
-type itemsMsg struct{}
-
-type commitMsg *git.Commit
-
-type countMsg int64
-
-type sessionState int
-
-const (
-	logState sessionState = iota
-	commitState
-	errorState
-)
-
-type item struct {
-	*git.Commit
-}
-
-func (i item) Title() string {
-	if i.Commit != nil {
-		return strings.Split(i.Commit.Message, "\n")[0]
-	}
-	return ""
-}
-
-func (i item) FilterValue() string { return i.Title() }
-
-type itemDelegate struct {
-	style *style.Styles
-}
-
-func (d itemDelegate) Height() int                               { return 1 }
-func (d itemDelegate) Spacing() int                              { return 0 }
-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	i, ok := listItem.(item)
-	if !ok {
-		return
-	}
-	if i.Commit == nil {
-		return
-	}
-
-	hash := i.ID.String()
-	leftMargin := d.style.LogItemSelector.GetMarginLeft() +
-		d.style.LogItemSelector.GetWidth() +
-		d.style.LogItemHash.GetMarginLeft() +
-		d.style.LogItemHash.GetWidth() +
-		d.style.LogItemInactive.GetMarginLeft()
-	title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…")
-	if index == m.Index() {
-		fmt.Fprint(w, d.style.LogItemSelector.Render(">")+
-			d.style.LogItemHash.Bold(true).Render(hash[:7])+
-			d.style.LogItemActive.Render(title))
-	} else {
-		fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+
-			d.style.LogItemHash.Render(hash[:7])+
-			d.style.LogItemInactive.Render(title))
-	}
-}
-
-type Bubble struct {
-	repo           common.GitRepo
-	count          int64
-	list           list.Model
-	state          sessionState
-	commitViewport *vp.ViewportBubble
-	ref            *git.Reference
-	style          *style.Styles
-	width          int
-	widthMargin    int
-	height         int
-	heightMargin   int
-	error          common.ErrMsg
-	spinner        spinner.Model
-	loading        bool
-	loadingStart   time.Time
-	selectedCommit *git.Commit
-	nextPage       int
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
-	l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
-	l.SetShowFilter(false)
-	l.SetShowHelp(false)
-	l.SetShowPagination(true)
-	l.SetShowStatusBar(false)
-	l.SetShowTitle(false)
-	l.SetFilteringEnabled(false)
-	l.DisableQuitKeybindings()
-	l.KeyMap.NextPage = common.NextPage
-	l.KeyMap.PrevPage = common.PrevPage
-	s := spinner.New()
-	s.Spinner = spinner.Dot
-	s.Style = styles.Spinner
-	b := &Bubble{
-		commitViewport: &vp.ViewportBubble{
-			Viewport: &viewport.Model{},
-		},
-		repo:         repo,
-		style:        styles,
-		state:        logState,
-		width:        width,
-		widthMargin:  widthMargin,
-		height:       height,
-		heightMargin: heightMargin,
-		list:         l,
-		spinner:      s,
-	}
-	b.SetSize(width, height)
-	return b
-}
-
-func (b *Bubble) countCommits() tea.Msg {
-	if b.ref == nil {
-		ref, err := b.repo.HEAD()
-		if err != nil {
-			return common.ErrMsg{Err: err}
-		}
-		b.ref = ref
-	}
-	count, err := b.repo.CountCommits(b.ref)
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	return countMsg(count)
-}
-
-func (b *Bubble) updateItems() tea.Msg {
-	if b.count == 0 {
-		b.count = int64(b.countCommits().(countMsg))
-	}
-	count := b.count
-	items := make([]list.Item, count)
-	page := b.nextPage
-	limit := b.list.Paginator.PerPage
-	skip := page * limit
-	// CommitsByPage pages start at 1
-	cc, err := b.repo.CommitsByPage(b.ref, page+1, limit)
-	if err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	for i, c := range cc {
-		idx := i + skip
-		if int64(idx) >= count {
-			break
-		}
-		items[idx] = item{c}
-	}
-	b.list.SetItems(items)
-	b.SetSize(b.width, b.height)
-	return itemsMsg{}
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) GotoTop() {
-	b.commitViewport.Viewport.GotoTop()
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return nil
-}
-
-func (b *Bubble) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.commitViewport.Viewport.Width = width - b.widthMargin
-	b.commitViewport.Viewport.Height = height - b.heightMargin
-	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
-	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-		cmds = append(cmds, b.updateItems)
-
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "C":
-			b.count = 0
-			b.loading = true
-			b.loadingStart = time.Now().Add(-waitBeforeLoading) // always show spinner
-			b.list.Select(0)
-			b.nextPage = 0
-			return b, tea.Batch(b.updateItems, b.spinner.Tick)
-		case "enter", "right", "l":
-			if b.state == logState {
-				i := b.list.SelectedItem()
-				if i != nil {
-					c, ok := i.(item)
-					if ok {
-						b.selectedCommit = c.Commit
-					}
-				}
-				cmds = append(cmds, b.loadCommit, b.spinner.Tick)
-			}
-		case "esc", "left", "h":
-			if b.state != logState {
-				b.state = logState
-				b.selectedCommit = nil
-			}
-		}
-		switch b.state {
-		case logState:
-			curPage := b.list.Paginator.Page
-			m, cmd := b.list.Update(msg)
-			b.list = m
-			if m.Paginator.Page != curPage {
-				b.loading = true
-				b.loadingStart = time.Now()
-				b.list.Paginator.Page = curPage
-				b.nextPage = m.Paginator.Page
-				cmds = append(cmds, b.updateItems, b.spinner.Tick)
-			}
-			cmds = append(cmds, cmd)
-		case commitState:
-			rv, cmd := b.commitViewport.Update(msg)
-			b.commitViewport = rv.(*vp.ViewportBubble)
-			cmds = append(cmds, cmd)
-		}
-		return b, tea.Batch(cmds...)
-	case itemsMsg:
-		b.loading = false
-		b.list.Paginator.Page = b.nextPage
-		if b.state != commitState {
-			b.state = logState
-		}
-	case countMsg:
-		b.count = int64(msg)
-	case common.ErrMsg:
-		b.error = msg
-		b.state = errorState
-		b.loading = false
-		return b, nil
-	case commitMsg:
-		b.loading = false
-		b.state = commitState
-	case refs.RefMsg:
-		b.ref = msg
-		b.count = 0
-		cmds = append(cmds, b.countCommits)
-	case spinner.TickMsg:
-		if b.loading {
-			s, cmd := b.spinner.Update(msg)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-			b.spinner = s
-		}
-	}
-
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) loadPatch(c *git.Commit) error {
-	var patch strings.Builder
-	style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize())
-	p, err := b.repo.Diff(c)
-	if err != nil {
-		return err
-	}
-	stats := strings.Split(p.Stats().String(), "\n")
-	for i, l := range stats {
-		ch := strings.Split(l, "|")
-		if len(ch) > 1 {
-			adddel := ch[len(ch)-1]
-			adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+"))
-			adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-"))
-			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
-		}
-	}
-	patch.WriteString(b.renderCommit(c))
-	fpl := len(p.Files)
-	if fpl > common.MaxDiffFiles {
-		patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error())
-	} else {
-		patch.WriteString("\n" + strings.Join(stats, "\n"))
-	}
-	if fpl <= common.MaxDiffFiles {
-		ps := ""
-		if len(strings.Split(ps, "\n")) > common.MaxDiffLines {
-			patch.WriteString("\n" + common.ErrDiffTooLong.Error())
-		} else {
-			patch.WriteString("\n" + b.renderDiff(p))
-		}
-	}
-	content := style.Render(patch.String())
-	b.commitViewport.Viewport.SetContent(content)
-	b.GotoTop()
-	return nil
-}
-
-func (b *Bubble) loadCommit() tea.Msg {
-	b.loading = true
-	b.loadingStart = time.Now()
-	c := b.selectedCommit
-	if err := b.loadPatch(c); err != nil {
-		return common.ErrMsg{Err: err}
-	}
-	return commitMsg(c)
-}
-
-func (b *Bubble) renderCommit(c *git.Commit) string {
-	s := strings.Builder{}
-	// FIXME: lipgloss prints empty lines when CRLF is used
-	// sanitize commit message from CRLF
-	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
-	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
-		b.style.LogCommitHash.Render("commit "+c.ID.String()),
-		b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
-		b.style.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
-		b.style.LogCommitBody.Render(msg),
-	))
-	return s.String()
-}
-
-func (b *Bubble) renderDiff(diff *git.Diff) string {
-	var s strings.Builder
-	var pr strings.Builder
-	diffChroma.Code = diff.Patch()
-	err := diffChroma.Render(&pr, common.RenderCtx)
-	if err != nil {
-		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
-	} else {
-		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
-	}
-	return s.String()
-}
-
-func (b *Bubble) View() string {
-	if b.loading && b.loadingStart.Add(waitBeforeLoading).Before(time.Now()) {
-		msg := fmt.Sprintf("%s loading commit", b.spinner.View())
-		if b.selectedCommit == nil {
-			msg += "s"
-		}
-		msg += "…"
-		return msg
-	}
-	switch b.state {
-	case logState:
-		return b.list.View()
-	case errorState:
-		return b.error.ViewWithPrefix(b.style, "Error")
-	case commitState:
-		return b.commitViewport.View()
-	default:
-		return ""
-	}
-}

tui/refs/bubble.go 🔗

@@ -1,185 +0,0 @@
-package refs
-
-import (
-	"fmt"
-	"io"
-	"sort"
-
-	"github.com/charmbracelet/bubbles/list"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-)
-
-type RefMsg = *git.Reference
-
-type item struct {
-	*git.Reference
-}
-
-func (i item) Short() string {
-	return i.Reference.Name().Short()
-}
-
-func (i item) FilterValue() string { return i.Short() }
-
-type items []item
-
-func (cl items) Len() int      { return len(cl) }
-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl items) Less(i, j int) bool {
-	return cl[i].Short() < cl[j].Short()
-}
-
-type itemDelegate struct {
-	style *style.Styles
-}
-
-func (d itemDelegate) Height() int                               { return 1 }
-func (d itemDelegate) Spacing() int                              { return 0 }
-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.style
-	i, ok := listItem.(item)
-	if !ok {
-		return
-	}
-
-	ref := i.Short()
-	if i.Reference.IsTag() {
-		ref = s.RefItemTag.Render(ref)
-	}
-	ref = s.RefItemBranch.Render(ref)
-	refMaxWidth := m.Width() -
-		s.RefItemSelector.GetMarginLeft() -
-		s.RefItemSelector.GetWidth() -
-		s.RefItemInactive.GetMarginLeft()
-	ref = common.TruncateString(ref, refMaxWidth, "…")
-	if index == m.Index() {
-		fmt.Fprint(w, s.RefItemSelector.Render(">")+
-			s.RefItemActive.Render(ref))
-	} else {
-		fmt.Fprint(w, s.LogItemSelector.Render(" ")+
-			s.RefItemInactive.Render(ref))
-	}
-}
-
-type Bubble struct {
-	repo         common.GitRepo
-	list         list.Model
-	style        *style.Styles
-	width        int
-	widthMargin  int
-	height       int
-	heightMargin int
-	ref          *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
-	head, err := repo.HEAD()
-	if err != nil {
-		return nil
-	}
-	l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
-	l.SetShowFilter(false)
-	l.SetShowHelp(false)
-	l.SetShowPagination(true)
-	l.SetShowStatusBar(false)
-	l.SetShowTitle(false)
-	l.SetFilteringEnabled(false)
-	l.DisableQuitKeybindings()
-	b := &Bubble{
-		repo:         repo,
-		style:        styles,
-		width:        width,
-		height:       height,
-		widthMargin:  widthMargin,
-		heightMargin: heightMargin,
-		list:         l,
-		ref:          head,
-	}
-	b.SetSize(width, height)
-	return b
-}
-
-func (b *Bubble) SetBranch(ref *git.Reference) (tea.Model, tea.Cmd) {
-	b.ref = ref
-	return b, func() tea.Msg {
-		return RefMsg(ref)
-	}
-}
-
-func (b *Bubble) reset() tea.Cmd {
-	cmd := b.updateItems()
-	b.SetSize(b.width, b.height)
-	return cmd
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return nil
-}
-
-func (b *Bubble) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
-	b.list.Styles.PaginationStyle = b.style.RefPaginator.Copy().Width(width - b.widthMargin)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) updateItems() tea.Cmd {
-	its := make(items, 0)
-	tags := make(items, 0)
-	refs, err := b.repo.References()
-	if err != nil {
-		return func() tea.Msg { return common.ErrMsg{Err: err} }
-	}
-	for _, r := range refs {
-		if r.IsTag() {
-			tags = append(tags, item{r})
-		} else if r.IsBranch() {
-			its = append(its, item{r})
-		}
-	}
-	sort.Sort(its)
-	sort.Sort(tags)
-	its = append(its, tags...)
-	itt := make([]list.Item, len(its))
-	for i, it := range its {
-		itt[i] = it
-	}
-	return b.list.SetItems(itt)
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "B":
-			return b, b.reset()
-		case "enter", "right", "l":
-			if b.list.Index() >= 0 {
-				ref := b.list.SelectedItem().(item).Reference
-				return b.SetBranch(ref)
-			}
-		}
-	}
-
-	l, cmd := b.list.Update(msg)
-	b.list = l
-	cmds = append(cmds, cmd)
-
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) View() string {
-	return b.list.View()
-}

tui/tree/bubble.go 🔗

@@ -1,341 +0,0 @@
-package tree
-
-import (
-	"fmt"
-	"io"
-	"io/fs"
-	"path/filepath"
-	"strings"
-
-	"github.com/charmbracelet/bubbles/list"
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/charmbracelet/soft-serve/tui/refs"
-	vp "github.com/charmbracelet/soft-serve/tui/viewport"
-	"github.com/dustin/go-humanize"
-)
-
-type fileMsg struct {
-	content string
-}
-
-type sessionState int
-
-const (
-	treeState sessionState = iota
-	fileState
-	errorState
-)
-
-type item struct {
-	entry *git.TreeEntry
-}
-
-func (i item) Name() string {
-	return i.entry.Name()
-}
-
-func (i item) Mode() fs.FileMode {
-	return i.entry.Mode()
-}
-
-func (i item) FilterValue() string { return i.Name() }
-
-type items []item
-
-func (cl items) Len() int      { return len(cl) }
-func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
-func (cl items) Less(i, j int) bool {
-	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
-		return cl[i].Name() < cl[j].Name()
-	} else if cl[i].entry.IsTree() {
-		return true
-	} else if cl[j].entry.IsTree() {
-		return false
-	} else {
-		return cl[i].Name() < cl[j].Name()
-	}
-}
-
-type itemDelegate struct {
-	style *style.Styles
-}
-
-func (d itemDelegate) Height() int                               { return 1 }
-func (d itemDelegate) Spacing() int                              { return 0 }
-func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil }
-func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
-	s := d.style
-	i, ok := listItem.(item)
-	if !ok {
-		return
-	}
-
-	name := i.Name()
-	size := humanize.Bytes(uint64(i.entry.Size()))
-	if i.entry.IsTree() {
-		size = ""
-		name = s.TreeFileDir.Render(name)
-	}
-	var cs lipgloss.Style
-	mode := i.Mode()
-	if index == m.Index() {
-		cs = s.TreeItemActive
-		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
-	} else {
-		cs = s.TreeItemInactive
-		fmt.Fprint(w, s.TreeItemSelector.Render(" "))
-	}
-	leftMargin := s.TreeItemSelector.GetMarginLeft() +
-		s.TreeItemSelector.GetWidth() +
-		s.TreeFileMode.GetMarginLeft() +
-		s.TreeFileMode.GetWidth() +
-		cs.GetMarginLeft()
-	rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size)
-	name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…")
-	sizeStyle := s.TreeFileSize.Copy().
-		Width(m.Width() -
-			leftMargin -
-			s.TreeFileSize.GetMarginLeft() -
-			lipgloss.Width(name)).
-		Align(lipgloss.Right)
-	fmt.Fprint(w, s.TreeFileMode.Render(mode.String())+
-		cs.Render(name)+
-		sizeStyle.Render(size))
-}
-
-type Bubble struct {
-	repo         common.GitRepo
-	list         list.Model
-	style        *style.Styles
-	width        int
-	widthMargin  int
-	height       int
-	heightMargin int
-	path         string
-	state        sessionState
-	error        common.ErrMsg
-	fileViewport *vp.ViewportBubble
-	lastSelected []int
-	ref          *git.Reference
-}
-
-func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble {
-	l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin)
-	l.SetShowFilter(false)
-	l.SetShowHelp(false)
-	l.SetShowPagination(true)
-	l.SetShowStatusBar(false)
-	l.SetShowTitle(false)
-	l.SetFilteringEnabled(false)
-	l.DisableQuitKeybindings()
-	l.KeyMap.NextPage = common.NextPage
-	l.KeyMap.PrevPage = common.PrevPage
-	l.Styles.NoItems = styles.TreeNoItems
-	b := &Bubble{
-		fileViewport: &vp.ViewportBubble{
-			Viewport: &viewport.Model{},
-		},
-		repo:         repo,
-		style:        styles,
-		width:        width,
-		height:       height,
-		widthMargin:  widthMargin,
-		heightMargin: heightMargin,
-		list:         l,
-		state:        treeState,
-	}
-	b.SetSize(width, height)
-	return b
-}
-
-func (b *Bubble) reset() tea.Cmd {
-	b.path = ""
-	b.state = treeState
-	b.lastSelected = make([]int, 0)
-	cmd := b.updateItems()
-	return cmd
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	head, err := b.repo.HEAD()
-	if err != nil {
-		return func() tea.Msg {
-			return common.ErrMsg{Err: err}
-		}
-	}
-	b.ref = head
-	return nil
-}
-
-func (b *Bubble) SetSize(width, height int) {
-	b.width = width
-	b.height = height
-	b.fileViewport.Viewport.Width = width - b.widthMargin
-	b.fileViewport.Viewport.Height = height - b.heightMargin
-	b.list.SetSize(width-b.widthMargin, height-b.heightMargin)
-	b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return nil
-}
-
-func (b *Bubble) updateItems() tea.Cmd {
-	files := make([]list.Item, 0)
-	dirs := make([]list.Item, 0)
-	t, err := b.repo.Tree(b.ref, b.path)
-	if err != nil {
-		return func() tea.Msg { return common.ErrMsg{Err: err} }
-	}
-	ents, err := t.Entries()
-	if err != nil {
-		return func() tea.Msg { return common.ErrMsg{Err: err} }
-	}
-	ents.Sort()
-	for _, e := range ents {
-		if e.IsTree() {
-			dirs = append(dirs, item{e})
-		} else {
-			files = append(files, item{e})
-		}
-	}
-	cmd := b.list.SetItems(append(dirs, files...))
-	b.list.Select(0)
-	b.SetSize(b.width, b.height)
-	return cmd
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.WindowSizeMsg:
-		b.SetSize(msg.Width, msg.Height)
-
-	case tea.KeyMsg:
-		if b.state == errorState {
-			ref, _ := b.repo.HEAD()
-			b.ref = ref
-			return b, tea.Batch(b.reset(), func() tea.Msg {
-				return ref
-			})
-		}
-
-		switch msg.String() {
-		case "F":
-			return b, b.reset()
-		case "enter", "right", "l":
-			if len(b.list.Items()) > 0 && b.state == treeState {
-				index := b.list.Index()
-				item := b.list.SelectedItem().(item)
-				mode := item.Mode()
-				b.path = filepath.Join(b.path, item.Name())
-				if mode.IsDir() {
-					b.lastSelected = append(b.lastSelected, index)
-					cmds = append(cmds, b.updateItems())
-				} else {
-					b.lastSelected = append(b.lastSelected, index)
-					cmds = append(cmds, b.loadFile(item))
-				}
-			}
-		case "esc", "left", "h":
-			if b.state != treeState {
-				b.state = treeState
-			}
-			p := filepath.Dir(b.path)
-			b.path = p
-			cmds = append(cmds, b.updateItems())
-			index := 0
-			if len(b.lastSelected) > 0 {
-				index = b.lastSelected[len(b.lastSelected)-1]
-				b.lastSelected = b.lastSelected[:len(b.lastSelected)-1]
-			}
-			b.list.Select(index)
-		}
-
-	case refs.RefMsg:
-		b.ref = msg
-		return b, b.reset()
-
-	case common.ErrMsg:
-		b.error = msg
-		b.state = errorState
-		return b, nil
-
-	case fileMsg:
-		content := b.renderFile(msg)
-		b.fileViewport.Viewport.SetContent(content)
-		b.fileViewport.Viewport.GotoTop()
-		b.state = fileState
-	}
-
-	switch b.state {
-	case fileState:
-		rv, cmd := b.fileViewport.Update(msg)
-		b.fileViewport = rv.(*vp.ViewportBubble)
-		cmds = append(cmds, cmd)
-	case treeState:
-		l, cmd := b.list.Update(msg)
-		b.list = l
-		cmds = append(cmds, cmd)
-	}
-
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) View() string {
-	switch b.state {
-	case treeState:
-		return b.list.View()
-	case errorState:
-		return b.error.ViewWithPrefix(b.style, "Error")
-	case fileState:
-		return b.fileViewport.View()
-	default:
-		return ""
-	}
-}
-
-func (b *Bubble) loadFile(i item) tea.Cmd {
-	return func() tea.Msg {
-		f := i.entry.File()
-		if i.Mode().IsDir() || f == nil {
-			return common.ErrMsg{Err: common.ErrInvalidFile}
-		}
-		bin, err := f.IsBinary()
-		if err != nil {
-			return common.ErrMsg{Err: err}
-		}
-		if bin {
-			return common.ErrMsg{Err: common.ErrBinaryFile}
-		}
-		c, err := f.Bytes()
-		if err != nil {
-			return common.ErrMsg{Err: err}
-		}
-		return fileMsg{
-			content: string(c),
-		}
-	}
-}
-
-func (b *Bubble) renderFile(m fileMsg) string {
-	s := strings.Builder{}
-	c := m.content
-	if len(strings.Split(c, "\n")) > common.MaxDiffLines {
-		s.WriteString(b.style.TreeNoItems.Render(common.ErrFileTooLarge.Error()))
-	} else {
-		w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize()
-		f, err := common.RenderFile(b.path, m.content, w)
-		if err != nil {
-			s.WriteString(err.Error())
-		} else {
-			s.WriteString(f)
-		}
-	}
-	return b.style.TreeFileContent.Copy().Width(b.width - b.widthMargin).Render(s.String())
-}

tui/viewport/viewport_patch.go 🔗

@@ -1,24 +0,0 @@
-package viewport
-
-import (
-	"github.com/charmbracelet/bubbles/viewport"
-	tea "github.com/charmbracelet/bubbletea"
-)
-
-type ViewportBubble struct {
-	Viewport *viewport.Model
-}
-
-func (v *ViewportBubble) Init() tea.Cmd {
-	return nil
-}
-
-func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	vp, cmd := v.Viewport.Update(msg)
-	v.Viewport = &vp
-	return v, cmd
-}
-
-func (v *ViewportBubble) View() string {
-	return v.Viewport.View()
-}

ui/common/common.go 🔗

@@ -0,0 +1,22 @@
+package common
+
+import (
+	"github.com/aymanbagabas/go-osc52"
+	"github.com/charmbracelet/soft-serve/ui/keymap"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+)
+
+// Common is a struct all components should embed.
+type Common struct {
+	Copy   *osc52.Output
+	Styles *styles.Styles
+	KeyMap *keymap.KeyMap
+	Width  int
+	Height int
+}
+
+// SetSize sets the width and height of the common struct.
+func (c *Common) SetSize(width, height int) {
+	c.Width = width
+	c.Height = height
+}

ui/common/component.go 🔗

@@ -0,0 +1,13 @@
+package common
+
+import (
+	"github.com/charmbracelet/bubbles/help"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// Component represents a Bubble Tea model that implements a SetSize function.
+type Component interface {
+	tea.Model
+	help.KeyMap
+	SetSize(width, height int)
+}

ui/common/error.go 🔗

@@ -0,0 +1,13 @@
+package common
+
+import tea "github.com/charmbracelet/bubbletea"
+
+// ErrorMsg is a Bubble Tea message that represents an error.
+type ErrorMsg error
+
+// ErrorCmd returns an ErrorMsg from error.
+func ErrorCmd(err error) tea.Cmd {
+	return func() tea.Msg {
+		return ErrorMsg(err)
+	}
+}

ui/common/style.go 🔗

@@ -0,0 +1,19 @@
+package common
+
+import (
+	"github.com/charmbracelet/glamour"
+	gansi "github.com/charmbracelet/glamour/ansi"
+)
+
+// StyleConfig returns the default Glamour style configuration.
+func StyleConfig() gansi.StyleConfig {
+	noColor := ""
+	s := glamour.DarkStyleConfig
+	s.Document.StylePrimitive.Color = &noColor
+	s.CodeBlock.Chroma.Text.Color = &noColor
+	s.CodeBlock.Chroma.Name.Color = &noColor
+	// This fixes an issue with the default style config. For example
+	// highlighting empty spaces with red in Dockerfile type.
+	s.CodeBlock.Chroma.Error.BackgroundColor = &noColor
+	return s
+}

ui/common/utils.go 🔗

@@ -0,0 +1,11 @@
+package common
+
+import "github.com/muesli/reflow/truncate"
+
+// TruncateString is a convenient wrapper around truncate.TruncateString.
+func TruncateString(s string, max int) string {
+	if max < 0 {
+		max = 0
+	}
+	return truncate.StringWithTail(s, uint(max), "…")
+}

ui/components/code/code.go 🔗

@@ -0,0 +1,259 @@
+package code
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+
+	"github.com/alecthomas/chroma/lexers"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/glamour"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
+	"github.com/muesli/termenv"
+)
+
+const (
+	tabWidth = 4
+)
+
+var (
+	lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239"))
+	lineBarStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("236"))
+)
+
+// 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
+}
+
+// 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.CodeNoContent.Copy(),
+		LineDigitStyle: lineDigitStyle,
+		LineBarStyle:   lineBarStyle,
+	}
+	st := common.StyleConfig()
+	r.styleConfig = st
+	r.renderContext = gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       st,
+	})
+	r.SetSize(c.Width, c.Height)
+	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)
+	r.Viewport.SetSize(width, height)
+}
+
+// SetContent sets the content of the Code.
+func (r *Code) SetContent(c, ext string) tea.Cmd {
+	r.content = c
+	r.extension = ext
+	return r.Init()
+}
+
+// Init implements tea.Model.
+func (r *Code) Init() tea.Cmd {
+	w := r.common.Width
+	c := r.content
+	if c == "" {
+		r.Viewport.Model.SetContent(r.NoContentStyle.String())
+		return nil
+	}
+	f, err := r.renderFile(r.extension, c, w)
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	r.Viewport.Model.SetContent(f)
+	return nil
+}
+
+// Update implements tea.Model.
+func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg.(type) {
+	case tea.WindowSizeMsg:
+		// Recalculate content width and line wrap.
+		cmds = append(cmds, r.Init())
+	}
+	v, cmd := r.Viewport.Update(msg)
+	r.Viewport = v.(*vp.Viewport)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	return r, tea.Batch(cmds...)
+}
+
+// View implements tea.View.
+func (r *Code) View() string {
+	return r.Viewport.View()
+}
+
+// GotoTop moves the viewport to the top of the log.
+func (r *Code) GotoTop() {
+	r.Viewport.GotoTop()
+}
+
+// GotoBottom moves the viewport to the bottom of the log.
+func (r *Code) GotoBottom() {
+	r.Viewport.GotoBottom()
+}
+
+// HalfViewDown moves the viewport down by half the viewport height.
+func (r *Code) HalfViewDown() {
+	r.Viewport.HalfViewDown()
+}
+
+// HalfViewUp moves the viewport up by half the viewport height.
+func (r *Code) HalfViewUp() {
+	r.Viewport.HalfViewUp()
+}
+
+// ViewUp moves the viewport up by a page.
+func (r *Code) ViewUp() []string {
+	return r.Viewport.ViewUp()
+}
+
+// ViewDown moves the viewport down by a page.
+func (r *Code) ViewDown() []string {
+	return r.Viewport.ViewDown()
+}
+
+// LineUp moves the viewport up by the given number of lines.
+func (r *Code) LineUp(n int) []string {
+	return r.Viewport.LineUp(n)
+}
+
+// LineDown moves the viewport down by the given number of lines.
+func (r *Code) LineDown(n int) []string {
+	return r.Viewport.LineDown(n)
+}
+
+// ScrollPercent returns the viewport's scroll percentage.
+func (r *Code) ScrollPercent() float64 {
+	return r.Viewport.ScrollPercent()
+}
+
+func (r *Code) glamourize(w int, md string) (string, error) {
+	r.renderMutex.Lock()
+	defer r.renderMutex.Unlock()
+	if w > 120 {
+		w = 120
+	}
+	tr, err := glamour.NewTermRenderer(
+		glamour.WithStyles(r.styleConfig),
+		glamour.WithWordWrap(w),
+	)
+
+	if err != nil {
+		return "", err
+	}
+	mdt, err := tr.Render(md)
+	if err != nil {
+		return "", err
+	}
+	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))
+	lexer := lexers.Fallback
+	if path == "" {
+		lexer = lexers.Analyse(content)
+	} else {
+		lexer = lexers.Match(path)
+	}
+	lang := ""
+	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)
+		}
+	}
+	return strings.Join(lines, "\n"), mll
+}

ui/components/footer/footer.go 🔗

@@ -0,0 +1,85 @@
+package footer
+
+import (
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+// Footer is a Bubble Tea model that displays help and other info.
+type Footer struct {
+	common common.Common
+	help   help.Model
+	keymap help.KeyMap
+}
+
+// New creates a new Footer.
+func New(c common.Common, keymap help.KeyMap) *Footer {
+	h := help.New()
+	h.Styles.ShortKey = c.Styles.HelpKey
+	h.Styles.ShortDesc = c.Styles.HelpValue
+	h.Styles.FullKey = c.Styles.HelpKey
+	h.Styles.FullDesc = c.Styles.HelpValue
+	f := &Footer{
+		common: c,
+		help:   h,
+		keymap: keymap,
+	}
+	f.SetSize(c.Width, c.Height)
+	return f
+}
+
+// SetSize implements common.Component.
+func (f *Footer) SetSize(width, height int) {
+	f.common.SetSize(width, height)
+	f.help.Width = width -
+		f.common.Styles.Footer.GetHorizontalFrameSize()
+}
+
+// Init implements tea.Model.
+func (f *Footer) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements tea.Model.
+func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return f, nil
+}
+
+// View implements tea.Model.
+func (f *Footer) View() string {
+	if f.keymap == nil {
+		return ""
+	}
+	s := f.common.Styles.Footer.Copy().
+		Width(f.common.Width)
+	helpView := f.help.View(f.keymap)
+	return s.Render(helpView)
+}
+
+// ShortHelp returns the short help key bindings.
+func (f *Footer) ShortHelp() []key.Binding {
+	return f.keymap.ShortHelp()
+}
+
+// FullHelp returns the full help key bindings.
+func (f *Footer) FullHelp() [][]key.Binding {
+	return f.keymap.FullHelp()
+}
+
+// ShowAll returns whether the full help is shown.
+func (f *Footer) ShowAll() bool {
+	return f.help.ShowAll
+}
+
+// SetShowAll sets whether the full help is shown.
+func (f *Footer) SetShowAll(show bool) {
+	f.help.ShowAll = show
+}
+
+// Height returns the height of the footer.
+func (f *Footer) Height() int {
+	return lipgloss.Height(f.View())
+}

ui/components/header/header.go 🔗

@@ -0,0 +1,44 @@
+package header
+
+import (
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+// Header represents a header component.
+type Header struct {
+	common common.Common
+	text   string
+}
+
+// New creates a new header component.
+func New(c common.Common, text string) *Header {
+	h := &Header{
+		common: c,
+		text:   text,
+	}
+	return h
+}
+
+// SetSize implements common.Component.
+func (h *Header) SetSize(width, height int) {
+	h.common.SetSize(width, height)
+}
+
+// Init implements tea.Model.
+func (h *Header) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements tea.Model.
+func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return h, nil
+}
+
+// View implements tea.Model.
+func (h *Header) View() string {
+	s := h.common.Styles.Header.Copy().Width(h.common.Width)
+	return s.Render(strings.TrimSpace(h.text))
+}

ui/components/selector/selector.go 🔗

@@ -0,0 +1,222 @@
+package selector
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+// Selector is a list of items that can be selected.
+type Selector struct {
+	list.Model
+	common      common.Common
+	active      int
+	filterState list.FilterState
+}
+
+// IdentifiableItem is an item that can be identified by a string. Implements list.DefaultItem.
+type IdentifiableItem interface {
+	list.DefaultItem
+	ID() string
+}
+
+// ItemDelegate is a wrapper around list.ItemDelegate.
+type ItemDelegate interface {
+	list.ItemDelegate
+}
+
+// SelectMsg is a message that is sent when an item is selected.
+type SelectMsg struct{ IdentifiableItem }
+
+// ActiveMsg is a message that is sent when an item is active but not selected.
+type ActiveMsg struct{ IdentifiableItem }
+
+// New creates a new selector.
+func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector {
+	itms := make([]list.Item, len(items))
+	for i, item := range items {
+		itms[i] = item
+	}
+	l := list.New(itms, delegate, common.Width, common.Height)
+	s := &Selector{
+		Model:  l,
+		common: common,
+	}
+	s.SetSize(common.Width, common.Height)
+	return s
+}
+
+// PerPage returns the number of items per page.
+func (s *Selector) PerPage() int {
+	return s.Model.Paginator.PerPage
+}
+
+// SetPage sets the current page.
+func (s *Selector) SetPage(page int) {
+	s.Model.Paginator.Page = page
+}
+
+// Page returns the current page.
+func (s *Selector) Page() int {
+	return s.Model.Paginator.Page
+}
+
+// TotalPages returns the total number of pages.
+func (s *Selector) TotalPages() int {
+	return s.Model.Paginator.TotalPages
+}
+
+// Select selects the item at the given index.
+func (s *Selector) Select(index int) {
+	s.Model.Select(index)
+}
+
+// SetShowTitle sets the show title flag.
+func (s *Selector) SetShowTitle(show bool) {
+	s.Model.SetShowTitle(show)
+}
+
+// SetShowHelp sets the show help flag.
+func (s *Selector) SetShowHelp(show bool) {
+	s.Model.SetShowHelp(show)
+}
+
+// SetShowStatusBar sets the show status bar flag.
+func (s *Selector) SetShowStatusBar(show bool) {
+	s.Model.SetShowStatusBar(show)
+}
+
+// DisableQuitKeybindings disables the quit keybindings.
+func (s *Selector) DisableQuitKeybindings() {
+	s.Model.DisableQuitKeybindings()
+}
+
+// SetShowFilter sets the show filter flag.
+func (s *Selector) SetShowFilter(show bool) {
+	s.Model.SetShowFilter(show)
+}
+
+// SetShowPagination sets the show pagination flag.
+func (s *Selector) SetShowPagination(show bool) {
+	s.Model.SetShowPagination(show)
+}
+
+// SetFilteringEnabled sets the filtering enabled flag.
+func (s *Selector) SetFilteringEnabled(enabled bool) {
+	s.Model.SetFilteringEnabled(enabled)
+}
+
+// SetSize implements common.Component.
+func (s *Selector) SetSize(width, height int) {
+	s.common.SetSize(width, height)
+	s.Model.SetSize(width, height)
+}
+
+// SetItems sets the items in the selector.
+func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd {
+	its := make([]list.Item, len(items))
+	for i, item := range items {
+		its[i] = item
+	}
+	return s.Model.SetItems(its)
+}
+
+// Index returns the index of the selected item.
+func (s *Selector) Index() int {
+	return s.Model.Index()
+}
+
+// Init implements tea.Model.
+func (s *Selector) Init() tea.Cmd {
+	return s.activeCmd
+}
+
+// Update implements tea.Model.
+func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.MouseMsg:
+		switch msg.Type {
+		case tea.MouseWheelUp:
+			s.Model.CursorUp()
+		case tea.MouseWheelDown:
+			s.Model.CursorDown()
+		}
+	case tea.KeyMsg:
+		filterState := s.Model.FilterState()
+		switch {
+		case key.Matches(msg, s.common.KeyMap.Help):
+			if filterState == list.Filtering {
+				return s, tea.Batch(cmds...)
+			}
+		case key.Matches(msg, s.common.KeyMap.Select):
+			if filterState != list.Filtering {
+				cmds = append(cmds, s.selectCmd)
+			}
+		}
+	case list.FilterMatchesMsg:
+		cmds = append(cmds, s.activeFilterCmd)
+	}
+	m, cmd := s.Model.Update(msg)
+	s.Model = m
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	// Track filter state and update active item when filter state changes.
+	filterState := s.Model.FilterState()
+	if s.filterState != filterState {
+		cmds = append(cmds, s.activeFilterCmd)
+	}
+	s.filterState = filterState
+	// Send ActiveMsg when index change.
+	if s.active != s.Model.Index() {
+		cmds = append(cmds, s.activeCmd)
+	}
+	s.active = s.Model.Index()
+	return s, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+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}
+}
+
+func (s *Selector) activeCmd() tea.Msg {
+	item := s.Model.SelectedItem()
+	i, ok := item.(IdentifiableItem)
+	if !ok {
+		return ActiveMsg{}
+	}
+	return ActiveMsg{i}
+}
+
+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()
+	if len(items) == 0 {
+		return nil
+	}
+	item := items[0]
+	i, ok := item.(IdentifiableItem)
+	if !ok {
+		return nil
+	}
+	return ActiveMsg{i}
+}

ui/components/statusbar/statusbar.go 🔗

@@ -0,0 +1,85 @@
+package statusbar
+
+import (
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/muesli/reflow/truncate"
+)
+
+// StatusBarMsg is a message sent to the status bar.
+type StatusBarMsg struct {
+	Key    string
+	Value  string
+	Info   string
+	Branch string
+}
+
+// StatusBar is a status bar model.
+type StatusBar struct {
+	common common.Common
+	msg    StatusBarMsg
+}
+
+// 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{
+		common: c,
+	}
+	return s
+}
+
+// SetSize implements common.Component.
+func (s *StatusBar) SetSize(width, height int) {
+	s.common.Width = width
+	s.common.Height = height
+}
+
+// Init implements tea.Model.
+func (s *StatusBar) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements tea.Model.
+func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case StatusBarMsg:
+		s.msg = msg
+	}
+	return s, nil
+}
+
+// View implements tea.Model.
+func (s *StatusBar) View() string {
+	st := s.common.Styles
+	w := lipgloss.Width
+	help := st.StatusBarHelp.Render("? Help")
+	key := st.StatusBarKey.Render(s.msg.Key)
+	info := ""
+	if s.msg.Info != "" {
+		info = st.StatusBarInfo.Render(s.msg.Info)
+	}
+	branch := st.StatusBarBranch.Render(s.msg.Branch)
+	maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help)
+	v := truncate.StringWithTail(s.msg.Value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…")
+	value := st.StatusBarValue.
+		Width(maxWidth).
+		Render(v)
+
+	return lipgloss.NewStyle().MaxWidth(s.common.Width).
+		Render(
+			lipgloss.JoinHorizontal(lipgloss.Top,
+				key,
+				value,
+				info,
+				branch,
+				help,
+			),
+		)
+}

ui/components/tabs/tabs.go 🔗

@@ -0,0 +1,101 @@
+package tabs
+
+import (
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+// SelectTabMsg is a message that contains the index of the tab to select.
+type SelectTabMsg int
+
+// ActiveTabMsg is a message that contains the index of the current active tab.
+type ActiveTabMsg int
+
+// Tabs is bubbletea component that displays a list of tabs.
+type Tabs struct {
+	common       common.Common
+	tabs         []string
+	activeTab    int
+	TabSeparator lipgloss.Style
+	TabInactive  lipgloss.Style
+	TabActive    lipgloss.Style
+}
+
+// New creates a new Tabs component.
+func New(c common.Common, tabs []string) *Tabs {
+	r := &Tabs{
+		common:       c,
+		tabs:         tabs,
+		activeTab:    0,
+		TabSeparator: c.Styles.TabSeparator,
+		TabInactive:  c.Styles.TabInactive,
+		TabActive:    c.Styles.TabActive,
+	}
+	return r
+}
+
+// SetSize implements common.Component.
+func (t *Tabs) SetSize(width, height int) {
+	t.common.SetSize(width, height)
+}
+
+// Init implements tea.Model.
+func (t *Tabs) Init() tea.Cmd {
+	t.activeTab = 0
+	return nil
+}
+
+// Update implements tea.Model.
+func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "tab":
+			t.activeTab = (t.activeTab + 1) % len(t.tabs)
+			cmds = append(cmds, t.activeTabCmd)
+		case "shift+tab":
+			t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs)
+			cmds = append(cmds, t.activeTabCmd)
+		}
+	case SelectTabMsg:
+		tab := int(msg)
+		if tab >= 0 && tab < len(t.tabs) {
+			t.activeTab = int(msg)
+		}
+	}
+	return t, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (t *Tabs) View() string {
+	s := strings.Builder{}
+	sep := t.TabSeparator
+	for i, tab := range t.tabs {
+		style := t.TabInactive.Copy()
+		if i == t.activeTab {
+			style = t.TabActive.Copy()
+		}
+		s.WriteString(style.Render(tab))
+		if i != len(t.tabs)-1 {
+			s.WriteString(sep.String())
+		}
+	}
+	return lipgloss.NewStyle().
+		MaxWidth(t.common.Width).
+		Render(s.String())
+}
+
+func (t *Tabs) activeTabCmd() tea.Msg {
+	return ActiveTabMsg(t.activeTab)
+}
+
+// SelectTabCmd is a bubbletea command that selects the tab at the given index.
+func SelectTabCmd(tab int) tea.Cmd {
+	return func() tea.Msg {
+		return SelectTabMsg(tab)
+	}
+}

ui/components/viewport/viewport.go 🔗

@@ -0,0 +1,97 @@
+package viewport
+
+import (
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+// Viewport represents a viewport component.
+type Viewport struct {
+	common common.Common
+	*viewport.Model
+}
+
+// New returns a new Viewport.
+func New(c common.Common) *Viewport {
+	vp := viewport.New(c.Width, c.Height)
+	vp.MouseWheelEnabled = true
+	return &Viewport{
+		common: c,
+		Model:  &vp,
+	}
+}
+
+// SetSize implements common.Component.
+func (v *Viewport) SetSize(width, height int) {
+	v.common.SetSize(width, height)
+	v.Model.Width = width
+	v.Model.Height = height
+}
+
+// Init implements tea.Model.
+func (v *Viewport) Init() tea.Cmd {
+	return nil
+}
+
+// Update implements tea.Model.
+func (v *Viewport) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	vp, cmd := v.Model.Update(msg)
+	v.Model = &vp
+	return v, cmd
+}
+
+// View implements tea.Model.
+func (v *Viewport) View() string {
+	return v.Model.View()
+}
+
+// SetContent sets the viewport's content.
+func (v *Viewport) SetContent(content string) {
+	v.Model.SetContent(content)
+}
+
+// GotoTop moves the viewport to the top of the log.
+func (v *Viewport) GotoTop() {
+	v.Model.GotoTop()
+}
+
+// GotoBottom moves the viewport to the bottom of the log.
+func (v *Viewport) GotoBottom() {
+	v.Model.GotoBottom()
+}
+
+// HalfViewDown moves the viewport down by half the viewport height.
+func (v *Viewport) HalfViewDown() {
+	v.Model.HalfViewDown()
+}
+
+// HalfViewUp moves the viewport up by half the viewport height.
+func (v *Viewport) HalfViewUp() {
+	v.Model.HalfViewUp()
+}
+
+// ViewUp moves the viewport up by a page.
+func (v *Viewport) ViewUp() []string {
+	return v.Model.ViewUp()
+}
+
+// ViewDown moves the viewport down by a page.
+func (v *Viewport) ViewDown() []string {
+	return v.Model.ViewDown()
+}
+
+// LineUp moves the viewport up by the given number of lines.
+func (v *Viewport) LineUp(n int) []string {
+	return v.Model.LineUp(n)
+}
+
+// LineDown moves the viewport down by the given number of lines.
+func (v *Viewport) LineDown(n int) []string {
+	return v.Model.LineDown(n)
+}
+
+// ScrollPercent returns the viewport's scroll percentage.
+func (v *Viewport) ScrollPercent() float64 {
+	return v.Model.ScrollPercent()
+}

ui/git.go 🔗

@@ -0,0 +1,25 @@
+package ui
+
+import (
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+// source is a wrapper around config.RepoSource that implements git.GitRepoSource.
+type source struct {
+	*config.RepoSource
+}
+
+// GetRepo implements git.GitRepoSource.
+func (s *source) GetRepo(name string) (git.GitRepo, error) {
+	return s.RepoSource.GetRepo(name)
+}
+
+// AllRepos implements git.GitRepoSource.
+func (s *source) AllRepos() []git.GitRepo {
+	rs := make([]git.GitRepo, 0)
+	for _, r := range s.RepoSource.AllRepos() {
+		rs = append(rs, r)
+	}
+	return rs
+}

ui/git/git.go 🔗

@@ -0,0 +1,42 @@
+package git
+
+import (
+	"errors"
+	"fmt"
+
+	"github.com/charmbracelet/soft-serve/git"
+)
+
+// ErrMissingRepo indicates that the requested repository could not be found.
+var ErrMissingRepo = errors.New("missing repo")
+
+// GitRepo is an interface for Git repositories.
+type GitRepo interface {
+	Repo() string
+	Name() string
+	Description() string
+	Readme() (string, string)
+	HEAD() (*git.Reference, error)
+	Commit(string) (*git.Commit, error)
+	CommitsByPage(*git.Reference, int, int) (git.Commits, error)
+	CountCommits(*git.Reference) (int64, error)
+	Diff(*git.Commit) (*git.Diff, error)
+	References() ([]*git.Reference, error)
+	Tree(*git.Reference, string) (*git.Tree, error)
+	IsPrivate() bool
+}
+
+// GitRepoSource is an interface for Git repository factory.
+type GitRepoSource interface {
+	GetRepo(string) (GitRepo, error)
+	AllRepos() []GitRepo
+}
+
+// RepoURL returns the URL of the repository.
+func RepoURL(host string, port int, name string) string {
+	p := ""
+	if port != 22 {
+		p += fmt.Sprintf(":%d", port)
+	}
+	return fmt.Sprintf("git clone ssh://%s/%s", host+p, name)
+}

ui/keymap/keymap.go 🔗

@@ -0,0 +1,205 @@
+package keymap
+
+import "github.com/charmbracelet/bubbles/key"
+
+// KeyMap is a map of key bindings for the UI.
+type KeyMap struct {
+	Quit      key.Binding
+	Up        key.Binding
+	Down      key.Binding
+	UpDown    key.Binding
+	LeftRight key.Binding
+	Arrows    key.Binding
+	Select    key.Binding
+	Section   key.Binding
+	Back      key.Binding
+	PrevPage  key.Binding
+	NextPage  key.Binding
+	Help      key.Binding
+
+	SelectItem key.Binding
+	BackItem   key.Binding
+
+	Copy key.Binding
+}
+
+// DefaultKeyMap returns the default key map.
+func DefaultKeyMap() *KeyMap {
+	km := new(KeyMap)
+
+	km.Quit = key.NewBinding(
+		key.WithKeys(
+			"q",
+			"ctrl+c",
+		),
+		key.WithHelp(
+			"q",
+			"quit",
+		),
+	)
+
+	km.Up = key.NewBinding(
+		key.WithKeys(
+			"up",
+			"k",
+		),
+		key.WithHelp(
+			"↑",
+			"up",
+		),
+	)
+
+	km.Down = key.NewBinding(
+		key.WithKeys(
+			"down",
+			"j",
+		),
+		key.WithHelp(
+			"↓",
+			"down",
+		),
+	)
+
+	km.UpDown = key.NewBinding(
+		key.WithKeys(
+			"up",
+			"down",
+			"k",
+			"j",
+		),
+		key.WithHelp(
+			"↑↓",
+			"navigate",
+		),
+	)
+
+	km.LeftRight = key.NewBinding(
+		key.WithKeys(
+			"left",
+			"h",
+			"right",
+			"l",
+		),
+		key.WithHelp(
+			"←→",
+			"navigate",
+		),
+	)
+
+	km.Arrows = key.NewBinding(
+		key.WithKeys(
+			"up",
+			"right",
+			"down",
+			"left",
+			"k",
+			"j",
+			"h",
+			"l",
+		),
+		key.WithHelp(
+			"↑←↓→",
+			"navigate",
+		),
+	)
+
+	km.Select = key.NewBinding(
+		key.WithKeys(
+			"enter",
+		),
+		key.WithHelp(
+			"enter",
+			"select",
+		),
+	)
+
+	km.Section = key.NewBinding(
+		key.WithKeys(
+			"tab",
+			"shift+tab",
+		),
+		key.WithHelp(
+			"tab",
+			"section",
+		),
+	)
+
+	km.Back = key.NewBinding(
+		key.WithKeys(
+			"esc",
+		),
+		key.WithHelp(
+			"esc",
+			"back",
+		),
+	)
+
+	km.PrevPage = key.NewBinding(
+		key.WithKeys(
+			"pgup",
+			"b",
+			"u",
+		),
+		key.WithHelp(
+			"pgup",
+			"prev page",
+		),
+	)
+
+	km.NextPage = key.NewBinding(
+		key.WithKeys(
+			"pgdown",
+			"f",
+			"d",
+		),
+		key.WithHelp(
+			"pgdn",
+			"next page",
+		),
+	)
+
+	km.Help = key.NewBinding(
+		key.WithKeys(
+			"?",
+		),
+		key.WithHelp(
+			"?",
+			"toggle help",
+		),
+	)
+
+	km.SelectItem = key.NewBinding(
+		key.WithKeys(
+			"l",
+			"right",
+		),
+		key.WithHelp(
+			"→",
+			"select",
+		),
+	)
+
+	km.BackItem = key.NewBinding(
+		key.WithKeys(
+			"h",
+			"left",
+		),
+		key.WithHelp(
+			"←",
+			"back",
+		),
+	)
+
+	km.Copy = key.NewBinding(
+		key.WithKeys(
+			"c",
+			"ctrl+c",
+		),
+		key.WithHelp(
+			"c",
+			"copy text",
+		),
+	)
+
+	return km
+}

ui/pages/repo/files.go 🔗

@@ -0,0 +1,390 @@
+package repo
+
+import (
+	"errors"
+	"fmt"
+	"path/filepath"
+
+	"github.com/alecthomas/chroma/lexers"
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/code"
+	"github.com/charmbracelet/soft-serve/ui/components/selector"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+type filesView int
+
+const (
+	filesViewFiles filesView = iota
+	filesViewContent
+)
+
+var (
+	errNoFileSelected = errors.New("no file selected")
+	errBinaryFile     = errors.New("binary file")
+	errFileTooLarge   = errors.New("file is too large")
+	errInvalidFile    = errors.New("invalid file")
+)
+
+var (
+	lineNo = key.NewBinding(
+		key.WithKeys("l"),
+		key.WithHelp("l", "toggle line numbers"),
+	)
+)
+
+// FileItemsMsg is a message that contains a list of files.
+type FileItemsMsg []selector.IdentifiableItem
+
+// FileContentMsg is a message that contains the content of a file.
+type FileContentMsg struct {
+	content string
+	ext     string
+}
+
+// Files is the model for the files view.
+type Files struct {
+	common         common.Common
+	selector       *selector.Selector
+	ref            *ggit.Reference
+	activeView     filesView
+	repo           git.GitRepo
+	code           *code.Code
+	path           string
+	currentItem    *FileItem
+	currentContent FileContentMsg
+	lastSelected   []int
+	lineNumber     bool
+}
+
+// NewFiles creates a new files model.
+func NewFiles(common common.Common) *Files {
+	f := &Files{
+		common:       common,
+		code:         code.New(common, "", ""),
+		activeView:   filesViewFiles,
+		lastSelected: make([]int, 0),
+		lineNumber:   true,
+	}
+	selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&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
+	f.selector = selector
+	f.code.SetShowLineNumber(f.lineNumber)
+	return f
+}
+
+// SetSize implements common.Component.
+func (f *Files) SetSize(width, height int) {
+	f.common.SetSize(width, height)
+	f.selector.SetSize(width, height)
+	f.code.SetSize(width, height)
+}
+
+// ShortHelp implements help.KeyMap.
+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:
+		return []key.Binding{}
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (f *Files) FullHelp() [][]key.Binding {
+	b := make([][]key.Binding, 0)
+	switch f.activeView {
+	case filesViewFiles:
+		copyKey := f.common.KeyMap.Copy
+		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{
+			{
+				k.CursorUp,
+				k.CursorDown,
+				k.NextPage,
+				k.PrevPage,
+			},
+			{
+				k.GoToStart,
+				k.GoToEnd,
+				copyKey,
+			},
+		}...)
+	case filesViewContent:
+		copyKey := f.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy content")
+		k := f.code.KeyMap
+		b = append(b, []key.Binding{
+			f.common.KeyMap.BackItem,
+		})
+		b = append(b, [][]key.Binding{
+			{
+				k.PageDown,
+				k.PageUp,
+				k.HalfPageDown,
+				k.HalfPageUp,
+			},
+		}...)
+		lc := []key.Binding{
+			k.Down,
+			k.Up,
+			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
+}
+
+// Init implements tea.Model.
+func (f *Files) Init() tea.Cmd {
+	f.path = ""
+	f.currentItem = nil
+	f.activeView = filesViewFiles
+	f.lastSelected = make([]int, 0)
+	f.selector.Select(0)
+	return f.updateFilesCmd
+}
+
+// Update implements tea.Model.
+func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		f.repo = git.GitRepo(msg)
+		cmds = append(cmds, f.Init())
+	case RefMsg:
+		f.ref = msg
+		cmds = append(cmds, f.Init())
+	case FileItemsMsg:
+		cmds = append(cmds,
+			f.selector.SetItems(msg),
+			updateStatusBarCmd,
+		)
+	case FileContentMsg:
+		f.activeView = filesViewContent
+		f.currentContent = msg
+		f.code.SetContent(msg.content, msg.ext)
+		f.code.GotoTop()
+		cmds = append(cmds, updateStatusBarCmd)
+	case selector.SelectMsg:
+		switch sel := msg.IdentifiableItem.(type) {
+		case FileItem:
+			f.currentItem = &sel
+			f.path = filepath.Join(f.path, sel.entry.Name())
+			if sel.entry.IsTree() {
+				cmds = append(cmds, f.selectTreeCmd)
+			} else {
+				cmds = append(cmds, f.selectFileCmd)
+			}
+		}
+	case tea.KeyMsg:
+		switch f.activeView {
+		case filesViewFiles:
+			switch msg.String() {
+			case "l", "right":
+				cmds = append(cmds, f.selector.SelectItem)
+			case "h", "left":
+				cmds = append(cmds, f.deselectItemCmd)
+			}
+		case filesViewContent:
+			keyStr := msg.String()
+			switch {
+			case keyStr == "h", keyStr == "left":
+				cmds = append(cmds, f.deselectItemCmd)
+			case key.Matches(msg, f.common.KeyMap.Copy):
+				f.common.Copy.Copy(f.currentContent.content)
+			case key.Matches(msg, lineNo):
+				f.lineNumber = !f.lineNumber
+				f.code.SetShowLineNumber(f.lineNumber)
+				cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext))
+			}
+		}
+	case tea.WindowSizeMsg:
+		switch f.activeView {
+		case filesViewFiles:
+			if f.repo != nil {
+				cmds = append(cmds, f.updateFilesCmd)
+			}
+		case filesViewContent:
+			if f.currentContent.content != "" {
+				m, cmd := f.code.Update(msg)
+				f.code = m.(*code.Code)
+				if cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+	}
+	switch f.activeView {
+	case filesViewFiles:
+		m, cmd := f.selector.Update(msg)
+		f.selector = m.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case filesViewContent:
+		m, cmd := f.code.Update(msg)
+		f.code = m.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return f, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (f *Files) View() string {
+	switch f.activeView {
+	case filesViewFiles:
+		return f.selector.View()
+	case filesViewContent:
+		return f.code.View()
+	default:
+		return ""
+	}
+}
+
+// StatusBarValue returns the status bar value.
+func (f *Files) StatusBarValue() string {
+	p := f.path
+	if p == "." {
+		return ""
+	}
+	return p
+}
+
+// StatusBarInfo returns the status bar info.
+func (f *Files) StatusBarInfo() string {
+	switch f.activeView {
+	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)
+	default:
+		return ""
+	}
+}
+
+func (f *Files) updateFilesCmd() tea.Msg {
+	files := make([]selector.IdentifiableItem, 0)
+	dirs := make([]selector.IdentifiableItem, 0)
+	if f.ref == nil {
+		return common.ErrorMsg(errNoRef)
+	}
+	t, err := f.repo.Tree(f.ref, f.path)
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	ents, err := t.Entries()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	ents.Sort()
+	for _, e := range ents {
+		if e.IsTree() {
+			dirs = append(dirs, FileItem{entry: e})
+		} else {
+			files = append(files, FileItem{entry: e})
+		}
+	}
+	return FileItemsMsg(append(dirs, files...))
+}
+
+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)
+		return f.updateFilesCmd()
+	}
+	return common.ErrorMsg(errNoFileSelected)
+}
+
+func (f *Files) selectFileCmd() tea.Msg {
+	i := f.currentItem
+	if i != nil && !i.entry.IsTree() {
+		fi := i.entry.File()
+		if i.Mode().IsDir() || f == nil {
+			return common.ErrorMsg(errInvalidFile)
+		}
+		bin, err := fi.IsBinary()
+		if err != nil {
+			f.path = filepath.Dir(f.path)
+			return common.ErrorMsg(err)
+		}
+		if bin {
+			f.path = filepath.Dir(f.path)
+			return common.ErrorMsg(errBinaryFile)
+		}
+		c, err := fi.Bytes()
+		if err != nil {
+			f.path = filepath.Dir(f.path)
+			return common.ErrorMsg(err)
+		}
+		f.lastSelected = append(f.lastSelected, f.selector.Index())
+		return FileContentMsg{string(c), i.entry.Name()}
+	}
+	return common.ErrorMsg(errNoFileSelected)
+}
+
+func (f *Files) deselectItemCmd() tea.Msg {
+	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
+}

ui/pages/repo/filesitem.go 🔗

@@ -0,0 +1,146 @@
+package repo
+
+import (
+	"fmt"
+	"io"
+	"io/fs"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/key"
+	"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/ui/common"
+	"github.com/dustin/go-humanize"
+)
+
+// FileItem is a list item for a file.
+type FileItem struct {
+	entry *git.TreeEntry
+}
+
+// ID returns the ID of the file item.
+func (i FileItem) ID() string {
+	return i.entry.Name()
+}
+
+// Title returns the title of the file item.
+func (i FileItem) Title() string {
+	return i.entry.Name()
+}
+
+// Description returns the description of the file item.
+func (i FileItem) Description() string {
+	return ""
+}
+
+// Mode returns the mode of the file item.
+func (i FileItem) Mode() fs.FileMode {
+	return i.entry.Mode()
+}
+
+// FilterValue implements list.Item.
+func (i FileItem) FilterValue() string { return i.Title() }
+
+// FileItems is a list of file items.
+type FileItems []FileItem
+
+// Len implements sort.Interface.
+func (cl FileItems) Len() int { return len(cl) }
+
+// Swap implements sort.Interface.
+func (cl FileItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] }
+
+// Less implements sort.Interface.
+func (cl FileItems) Less(i, j int) bool {
+	if cl[i].entry.IsTree() && cl[j].entry.IsTree() {
+		return cl[i].Title() < cl[j].Title()
+	} else if cl[i].entry.IsTree() {
+		return true
+	} else if cl[j].entry.IsTree() {
+		return false
+	} else {
+		return cl[i].Title() < cl[j].Title()
+	}
+}
+
+// FileItemDelegate is the delegate for the file item list.
+type FileItemDelegate struct {
+	common *common.Common
+}
+
+// Height returns the height of the file item list. Implements list.ItemDelegate.
+func (d FileItemDelegate) Height() int { return 1 }
+
+// Spacing returns the spacing of the file item list. Implements list.ItemDelegate.
+func (d FileItemDelegate) Spacing() int { return 0 }
+
+// Update implements list.ItemDelegate.
+func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(FileItem)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			d.common.Copy.Copy(item.Title())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
+
+// Render implements list.ItemDelegate.
+func (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	s := d.common.Styles
+	i, ok := listItem.(FileItem)
+	if !ok {
+		return
+	}
+
+	name := i.Title()
+	size := humanize.Bytes(uint64(i.entry.Size()))
+	size = strings.ReplaceAll(size, " ", "")
+	sizeLen := lipgloss.Width(size)
+	if i.entry.IsTree() {
+		size = strings.Repeat(" ", sizeLen)
+		name = s.TreeFileDir.Render(name)
+	}
+	var cs lipgloss.Style
+	mode := i.Mode()
+	if index == m.Index() {
+		cs = s.TreeItemActive
+		fmt.Fprint(w, s.TreeItemSelector.Render(">"))
+	} else {
+		cs = s.TreeItemInactive
+		fmt.Fprint(w, s.TreeItemSelector.Render(" "))
+	}
+	sizeStyle := s.TreeFileSize.Copy().
+		Width(8).
+		Align(lipgloss.Right).
+		MarginLeft(1)
+	leftMargin := s.TreeItemSelector.GetMarginLeft() +
+		s.TreeItemSelector.GetWidth() +
+		s.TreeFileMode.GetMarginLeft() +
+		s.TreeFileMode.GetWidth() +
+		cs.GetMarginLeft() +
+		sizeStyle.GetHorizontalFrameSize()
+	name = common.TruncateString(name, m.Width()-leftMargin)
+	name = cs.Render(name)
+	size = sizeStyle.Render(size)
+	modeStr := s.TreeFileMode.Render(mode.String())
+	truncate := lipgloss.NewStyle().MaxWidth(m.Width() -
+		s.TreeItemSelector.GetHorizontalFrameSize() -
+		s.TreeItemSelector.GetWidth())
+	fmt.Fprint(w,
+		truncate.Render(fmt.Sprintf("%s%s%s",
+			modeStr,
+			size,
+			name,
+		)),
+	)
+}

ui/pages/repo/log.go 🔗

@@ -0,0 +1,476 @@
+package repo
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/lipgloss"
+	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/selector"
+	"github.com/charmbracelet/soft-serve/ui/components/viewport"
+	"github.com/charmbracelet/soft-serve/ui/git"
+	"github.com/muesli/reflow/wrap"
+	"github.com/muesli/termenv"
+)
+
+var (
+	waitBeforeLoading = time.Millisecond * 100
+)
+
+type logView int
+
+const (
+	logViewCommits logView = iota
+	logViewDiff
+)
+
+// LogCountMsg is a message that contains the number of commits in a repo.
+type LogCountMsg int64
+
+// LogItemsMsg is a message that contains a slice of LogItem.
+type LogItemsMsg []selector.IdentifiableItem
+
+// LogCommitMsg is a message that contains a git commit.
+type LogCommitMsg *ggit.Commit
+
+// LogDiffMsg is a message that contains a git diff.
+type LogDiffMsg *ggit.Diff
+
+// Log is a model that displays a list of commits and their diffs.
+type Log struct {
+	common         common.Common
+	selector       *selector.Selector
+	vp             *viewport.Viewport
+	activeView     logView
+	repo           git.GitRepo
+	ref            *ggit.Reference
+	count          int64
+	nextPage       int
+	activeCommit   *ggit.Commit
+	selectedCommit *ggit.Commit
+	currentDiff    *ggit.Diff
+	loadingTime    time.Time
+	loading        bool
+	spinner        spinner.Model
+}
+
+// NewLog creates a new Log model.
+func NewLog(common common.Common) *Log {
+	l := &Log{
+		common:     common,
+		vp:         viewport.New(common),
+		activeView: logViewCommits,
+	}
+	selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&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
+	l.selector = selector
+	s := spinner.New()
+	s.Spinner = spinner.Dot
+	s.Style = common.Styles.Spinner
+	l.spinner = s
+	return l
+}
+
+// SetSize implements common.Component.
+func (l *Log) SetSize(width, height int) {
+	l.common.SetSize(width, height)
+	l.selector.SetSize(width, height)
+	l.vp.SetSize(width, height)
+}
+
+// ShortHelp implements help.KeyMap.
+func (l *Log) ShortHelp() []key.Binding {
+	switch l.activeView {
+	case logViewCommits:
+		copyKey := l.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy hash")
+		return []key.Binding{
+			l.common.KeyMap.UpDown,
+			l.common.KeyMap.SelectItem,
+			copyKey,
+		}
+	case logViewDiff:
+		return []key.Binding{
+			l.common.KeyMap.UpDown,
+			l.common.KeyMap.BackItem,
+		}
+	default:
+		return []key.Binding{}
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (l *Log) FullHelp() [][]key.Binding {
+	k := l.selector.KeyMap
+	b := make([][]key.Binding, 0)
+	switch l.activeView {
+	case logViewCommits:
+		copyKey := l.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy hash")
+		b = append(b, []key.Binding{
+			l.common.KeyMap.SelectItem,
+			l.common.KeyMap.BackItem,
+		})
+		b = append(b, [][]key.Binding{
+			{
+				copyKey,
+				k.CursorUp,
+				k.CursorDown,
+			},
+			{
+				k.NextPage,
+				k.PrevPage,
+				k.GoToStart,
+				k.GoToEnd,
+			},
+		}...)
+	case logViewDiff:
+		k := l.vp.KeyMap
+		b = append(b, []key.Binding{
+			l.common.KeyMap.BackItem,
+		})
+		b = append(b, [][]key.Binding{
+			{
+				k.PageDown,
+				k.PageUp,
+				k.HalfPageDown,
+				k.HalfPageUp,
+			},
+			{
+				k.Down,
+				k.Up,
+			},
+		}...)
+	}
+	return b
+}
+
+func (l *Log) startLoading() tea.Cmd {
+	l.loadingTime = time.Now()
+	l.loading = true
+	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
+	l.nextPage = 0
+	l.count = 0
+	l.activeCommit = nil
+	l.selectedCommit = nil
+	l.selector.Select(0)
+	return tea.Batch(
+		l.updateCommitsCmd,
+		// start loading on init
+		l.startLoading(),
+	)
+}
+
+// Update implements tea.Model.
+func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		l.repo = git.GitRepo(msg)
+		cmds = append(cmds, l.Init())
+	case RefMsg:
+		l.ref = msg
+		cmds = append(cmds, l.Init())
+	case LogCountMsg:
+		l.count = int64(msg)
+	case LogItemsMsg:
+		cmds = append(cmds,
+			l.selector.SetItems(msg),
+			// stop loading after receiving items
+			l.stopLoading(),
+		)
+		l.selector.SetPage(l.nextPage)
+		l.SetSize(l.common.Width, l.common.Height)
+		i := l.selector.SelectedItem()
+		if i != nil {
+			l.activeCommit = i.(LogItem).Commit
+		}
+	case tea.KeyMsg, tea.MouseMsg:
+		switch l.activeView {
+		case logViewCommits:
+			switch key := msg.(type) {
+			case tea.KeyMsg:
+				switch key.String() {
+				case "l", "right":
+					cmds = append(cmds, l.selector.SelectItem)
+				}
+			}
+			// 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)
+			l.selector = m
+			if m.Page() != curPage {
+				l.nextPage = m.Page()
+				l.selector.SetPage(curPage)
+				cmds = append(cmds,
+					l.updateCommitsCmd,
+					l.startLoading(),
+				)
+			}
+			cmds = append(cmds, cmd)
+		case logViewDiff:
+			switch key := msg.(type) {
+			case tea.KeyMsg:
+				switch key.String() {
+				case "h", "left":
+					l.activeView = logViewCommits
+					l.selectedCommit = nil
+				}
+			}
+		}
+	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:
+			cmds = append(cmds,
+				l.selectCommitCmd(sel.Commit),
+				l.startLoading(),
+			)
+		}
+	case LogCommitMsg:
+		l.selectedCommit = msg
+		cmds = append(cmds, l.loadDiffCmd)
+	case LogDiffMsg:
+		l.currentDiff = msg
+		l.vp.SetContent(
+			lipgloss.JoinVertical(lipgloss.Top,
+				l.renderCommit(l.selectedCommit),
+				l.renderSummary(msg),
+				l.renderDiff(msg),
+			),
+		)
+		l.vp.GotoTop()
+		l.activeView = logViewDiff
+		cmds = append(cmds,
+			updateStatusBarCmd,
+			// stop loading after setting the viewport content
+			l.stopLoading(),
+		)
+	case tea.WindowSizeMsg:
+		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),
+				),
+			)
+		}
+		if l.repo != nil {
+			cmds = append(cmds,
+				l.updateCommitsCmd,
+				// start loading on resize since the number of commits per page
+				// might change and we'd need to load more commits.
+				l.startLoading(),
+			)
+		}
+	}
+	if l.loading {
+		s, cmd := l.spinner.Update(msg)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		l.spinner = s
+	}
+	switch l.activeView {
+	case logViewDiff:
+		vp, cmd := l.vp.Update(msg)
+		l.vp = vp.(*viewport.Viewport)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return l, tea.Batch(cmds...)
+}
+
+// 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 msg
+	}
+	switch l.activeView {
+	case logViewCommits:
+		return l.selector.View()
+	case logViewDiff:
+		return l.vp.View()
+	default:
+		return ""
+	}
+}
+
+// StatusBarValue returns the status bar value.
+func (l *Log) StatusBarValue() string {
+	if l.loading {
+		return ""
+	}
+	c := l.activeCommit
+	if c == nil {
+		return ""
+	}
+	who := c.Author.Name
+	if email := c.Author.Email; email != "" {
+		who += " <" + email + ">"
+	}
+	value := c.ID.String()
+	if who != "" {
+		value += " by " + who
+	}
+	return value
+}
+
+// StatusBarInfo returns the status bar info.
+func (l *Log) StatusBarInfo() string {
+	switch l.activeView {
+	case logViewCommits:
+		// We're using l.nextPage instead of l.selector.Paginator.Page because
+		// of the paginator hack above.
+		return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages())
+	case logViewDiff:
+		return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100)
+	default:
+		return ""
+	}
+}
+
+func (l *Log) countCommitsCmd() tea.Msg {
+	if l.ref == nil {
+		return common.ErrorMsg(errNoRef)
+	}
+	count, err := l.repo.CountCommits(l.ref)
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	return LogCountMsg(count)
+}
+
+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 common.ErrorMsg(errNoRef)
+	}
+	items := make([]selector.IdentifiableItem, count)
+	page := l.nextPage
+	limit := l.selector.PerPage()
+	skip := page * limit
+	// CommitsByPage pages start at 1
+	cc, err := l.repo.CommitsByPage(l.ref, page+1, limit)
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	for i, c := range cc {
+		idx := i + skip
+		if int64(idx) >= count {
+			break
+		}
+		items[idx] = LogItem{Commit: c}
+	}
+	return LogItemsMsg(items)
+}
+
+func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd {
+	return func() tea.Msg {
+		return LogCommitMsg(commit)
+	}
+}
+
+func (l *Log) loadDiffCmd() tea.Msg {
+	diff, err := l.repo.Diff(l.selectedCommit)
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	return LogDiffMsg(diff)
+}
+
+func renderCtx() gansi.RenderContext {
+	return gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       common.StyleConfig(),
+	})
+}
+
+func (l *Log) renderCommit(c *ggit.Commit) string {
+	s := strings.Builder{}
+	// FIXME: lipgloss prints empty lines when CRLF is used
+	// sanitize commit message from CRLF
+	msg := strings.ReplaceAll(c.Message, "\r\n", "\n")
+	s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n",
+		l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()),
+		l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)),
+		l.common.Styles.LogCommitDate.Render("Date:   "+c.Committer.When.Format(time.UnixDate)),
+		l.common.Styles.LogCommitBody.Render(msg),
+	))
+	return wrap.String(s.String(), l.common.Width-2)
+}
+
+func (l *Log) renderSummary(diff *ggit.Diff) 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.LogCommitStatsAdd.Render("+"))
+			adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-"))
+			stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel
+		}
+	}
+	return wrap.String(strings.Join(stats, "\n"), l.common.Width-2)
+}
+
+func (l *Log) renderDiff(diff *ggit.Diff) string {
+	var s strings.Builder
+	var pr strings.Builder
+	diffChroma := &gansi.CodeBlockElement{
+		Code:     diff.Patch(),
+		Language: "diff",
+	}
+	err := diffChroma.Render(&pr, renderCtx())
+	if err != nil {
+		s.WriteString(fmt.Sprintf("\n%s", err.Error()))
+	} else {
+		s.WriteString(fmt.Sprintf("\n%s", pr.String()))
+	}
+	return wrap.String(s.String(), l.common.Width)
+}

ui/pages/repo/logitem.go 🔗

@@ -0,0 +1,155 @@
+package repo
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"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/ui/common"
+	"github.com/muesli/reflow/truncate"
+)
+
+// LogItem is a item in the log list that displays a git commit.
+type LogItem struct {
+	*git.Commit
+	copied time.Time
+}
+
+// ID implements selector.IdentifiableItem.
+func (i LogItem) ID() string {
+	return i.Hash()
+}
+
+func (i LogItem) Hash() string {
+	return i.Commit.ID.String()
+}
+
+// Title returns the item title. Implements list.DefaultItem.
+func (i LogItem) Title() string {
+	if i.Commit != nil {
+		return strings.Split(i.Commit.Message, "\n")[0]
+	}
+	return ""
+}
+
+// Description returns the item description. Implements list.DefaultItem.
+func (i LogItem) Description() string { return "" }
+
+// FilterValue implements list.Item.
+func (i LogItem) FilterValue() string { return i.Title() }
+
+// LogItemDelegate is the delegate for LogItem.
+type LogItemDelegate struct {
+	common *common.Common
+}
+
+// Height returns the item height. Implements list.ItemDelegate.
+func (d LogItemDelegate) Height() int { return 2 }
+
+// Spacing returns the item spacing. Implements list.ItemDelegate.
+func (d LogItemDelegate) Spacing() int { return 1 }
+
+// Update updates the item. Implements list.ItemDelegate.
+func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(LogItem)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			item.copied = time.Now()
+			d.common.Copy.Copy(item.Hash())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
+
+var (
+	faint = func(s string) string { return lipgloss.NewStyle().Faint(true).Render(s) }
+)
+
+// Render renders the item. Implements list.ItemDelegate.
+func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	styles := d.common.Styles
+	i, ok := listItem.(LogItem)
+	if !ok {
+		return
+	}
+	if i.Commit == nil {
+		return
+	}
+
+	titleStyle := styles.LogItemTitle.Copy()
+	style := styles.LogItemInactive
+	if index == m.Index() {
+		titleStyle.Bold(true)
+		style = styles.LogItemActive
+	}
+	hash := i.Commit.ID.String()[:7]
+	if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
+		hash = "copied"
+	}
+	title := titleStyle.Render(
+		common.TruncateString(i.Title(),
+			m.Width()-
+				style.GetHorizontalFrameSize()-
+				// 9 is the length of the hash (7) + the left padding (1) + the
+				// title truncation symbol (1)
+				9),
+	)
+	hashStyle := styles.LogItemHash.Copy().
+		Align(lipgloss.Right).
+		PaddingLeft(1).
+		Width(m.Width() -
+			style.GetHorizontalFrameSize() -
+			lipgloss.Width(title) - 1) // 1 is for the left padding
+	if index == m.Index() {
+		hashStyle = hashStyle.Bold(true)
+	}
+	hash = hashStyle.Render(hash)
+	if m.Width()-style.GetHorizontalFrameSize()-hashStyle.GetHorizontalFrameSize()-hashStyle.GetWidth() <= 0 {
+		hash = ""
+		title = titleStyle.Render(
+			common.TruncateString(i.Title(),
+				m.Width()-style.GetHorizontalFrameSize()),
+		)
+	}
+	author := i.Author.Name
+	commiter := i.Committer.Name
+	who := ""
+	if author != "" && commiter != "" {
+		who = commiter + faint(" committed")
+		if author != commiter {
+			who = author + faint(" authored and ") + who
+		}
+		who += " "
+	}
+	date := fmt.Sprintf("on %s", i.Committer.When.Format("Feb 02"))
+	date = faint(date)
+	if i.Committer.When.Year() != time.Now().Year() {
+		date += fmt.Sprintf(" %d", i.Committer.When.Year())
+	}
+	who += date
+	who = common.TruncateString(who, m.Width()-style.GetHorizontalFrameSize())
+	fmt.Fprint(w,
+		style.Render(
+			lipgloss.JoinVertical(lipgloss.Top,
+				truncate.String(fmt.Sprintf("%s%s",
+					title,
+					hash,
+				), uint(m.Width()-style.GetHorizontalFrameSize())),
+				who,
+			),
+		),
+	)
+}

ui/pages/repo/readme.go 🔗

@@ -0,0 +1,114 @@
+package repo
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/code"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+type ReadmeMsg struct{}
+
+// Readme is the readme component page.
+type Readme struct {
+	common common.Common
+	code   *code.Code
+	ref    RefMsg
+	repo   git.GitRepo
+}
+
+// NewReadme creates a new readme model.
+func NewReadme(common common.Common) *Readme {
+	readme := code.New(common, "", "")
+	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
+	return &Readme{
+		code:   readme,
+		common: common,
+	}
+}
+
+// SetSize implements common.Component.
+func (r *Readme) SetSize(width, height int) {
+	r.common.SetSize(width, height)
+	r.code.SetSize(width, height)
+}
+
+// ShortHelp implements help.KeyMap.
+func (r *Readme) ShortHelp() []key.Binding {
+	b := []key.Binding{
+		r.common.KeyMap.UpDown,
+	}
+	return b
+}
+
+// FullHelp implements help.KeyMap.
+func (r *Readme) FullHelp() [][]key.Binding {
+	k := r.code.KeyMap
+	b := [][]key.Binding{
+		{
+			k.PageDown,
+			k.PageUp,
+			k.HalfPageDown,
+			k.HalfPageUp,
+		},
+		{
+			k.Down,
+			k.Up,
+		},
+	}
+	return b
+}
+
+// Init implements tea.Model.
+func (r *Readme) Init() tea.Cmd {
+	if r.repo == nil {
+		return common.ErrorCmd(git.ErrMissingRepo)
+	}
+	rm, rp := r.repo.Readme()
+	r.code.GotoTop()
+	return tea.Batch(
+		r.code.SetContent(rm, rp),
+		r.updateReadmeCmd,
+	)
+}
+
+// Update implements tea.Model.
+func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		r.repo = git.GitRepo(msg)
+		cmds = append(cmds, r.Init())
+	case RefMsg:
+		r.ref = msg
+		cmds = append(cmds, r.Init())
+	}
+	c, cmd := r.code.Update(msg)
+	r.code = c.(*code.Code)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	return r, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (r *Readme) View() string {
+	return r.code.View()
+}
+
+// StatusBarValue implements statusbar.StatusBar.
+func (r *Readme) StatusBarValue() string {
+	return ""
+}
+
+// StatusBarInfo implements statusbar.StatusBar.
+func (r *Readme) StatusBarInfo() string {
+	return fmt.Sprintf("☰ %.f%%", r.code.ScrollPercent()*100)
+}
+
+func (r *Readme) updateReadmeCmd() tea.Msg {
+	return ReadmeMsg{}
+}

ui/pages/repo/refs.go 🔗

@@ -0,0 +1,196 @@
+package repo
+
+import (
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/selector"
+	"github.com/charmbracelet/soft-serve/ui/components/tabs"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+var (
+	errNoRef = errors.New("no reference specified")
+)
+
+// RefItemsMsg is a message that contains a list of RefItem.
+type RefItemsMsg struct {
+	prefix string
+	items  []selector.IdentifiableItem
+}
+
+// Refs is a component that displays a list of references.
+type Refs struct {
+	common    common.Common
+	selector  *selector.Selector
+	repo      git.GitRepo
+	ref       *ggit.Reference
+	activeRef *ggit.Reference
+	refPrefix string
+}
+
+// NewRefs creates a new Refs component.
+func NewRefs(common common.Common, refPrefix string) *Refs {
+	r := &Refs{
+		common:    common,
+		refPrefix: refPrefix,
+	}
+	s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common})
+	s.SetShowFilter(false)
+	s.SetShowHelp(false)
+	s.SetShowPagination(false)
+	s.SetShowStatusBar(false)
+	s.SetShowTitle(false)
+	s.SetFilteringEnabled(false)
+	s.DisableQuitKeybindings()
+	r.selector = s
+	return r
+}
+
+// SetSize implements common.Component.
+func (r *Refs) SetSize(width, height int) {
+	r.common.SetSize(width, height)
+	r.selector.SetSize(width, height)
+}
+
+// ShortHelp implements help.KeyMap.
+func (r *Refs) ShortHelp() []key.Binding {
+	copyKey := r.common.KeyMap.Copy
+	copyKey.SetHelp("c", "copy ref")
+	k := r.selector.KeyMap
+	return []key.Binding{
+		r.common.KeyMap.SelectItem,
+		k.CursorUp,
+		k.CursorDown,
+		copyKey,
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (r *Refs) FullHelp() [][]key.Binding {
+	copyKey := r.common.KeyMap.Copy
+	copyKey.SetHelp("c", "copy ref")
+	k := r.selector.KeyMap
+	return [][]key.Binding{
+		{r.common.KeyMap.SelectItem},
+		{
+			k.CursorUp,
+			k.CursorDown,
+			k.NextPage,
+			k.PrevPage,
+		},
+		{
+			k.GoToStart,
+			k.GoToEnd,
+			copyKey,
+		},
+	}
+}
+
+// Init implements tea.Model.
+func (r *Refs) Init() tea.Cmd {
+	return r.updateItemsCmd
+}
+
+// Update implements tea.Model.
+func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		r.selector.Select(0)
+		r.repo = git.GitRepo(msg)
+		cmds = append(cmds, r.Init())
+	case RefMsg:
+		r.ref = msg
+		cmds = append(cmds, r.Init())
+	case RefItemsMsg:
+		if r.refPrefix == msg.prefix {
+			cmds = append(cmds, r.selector.SetItems(msg.items))
+			i := r.selector.SelectedItem()
+			if i != nil {
+				r.activeRef = i.(RefItem).Reference
+			}
+		}
+	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)),
+			)
+		}
+	case tea.KeyMsg:
+		switch msg.String() {
+		case "l", "right":
+			cmds = append(cmds, r.selector.SelectItem)
+		}
+	}
+	m, cmd := r.selector.Update(msg)
+	r.selector = m.(*selector.Selector)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	return r, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (r *Refs) View() string {
+	return r.selector.View()
+}
+
+// StatusBarValue implements statusbar.StatusBar.
+func (r *Refs) StatusBarValue() string {
+	if r.activeRef == nil {
+		return ""
+	}
+	return r.activeRef.Name().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)
+	}
+	return ""
+}
+
+func (r *Refs) updateItemsCmd() tea.Msg {
+	its := make(RefItems, 0)
+	refs, err := r.repo.References()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	for _, ref := range refs {
+		if strings.HasPrefix(ref.Name().String(), r.refPrefix) {
+			its = append(its, RefItem{Reference: ref})
+		}
+	}
+	sort.Sort(its)
+	items := make([]selector.IdentifiableItem, len(its))
+	for i, it := range its {
+		items[i] = it
+	}
+	return RefItemsMsg{
+		items:  items,
+		prefix: r.refPrefix,
+	}
+}
+
+func switchRefCmd(ref *ggit.Reference) tea.Cmd {
+	return func() tea.Msg {
+		return RefMsg(ref)
+	}
+}

ui/pages/repo/refsitem.go 🔗

@@ -0,0 +1,111 @@
+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/git"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+// RefItem is a git reference item.
+type RefItem struct {
+	*git.Reference
+}
+
+// ID implements selector.IdentifiableItem.
+func (i RefItem) ID() string {
+	return i.Reference.Name().String()
+}
+
+// Title implements list.DefaultItem.
+func (i RefItem) Title() string {
+	return i.Reference.Name().Short()
+}
+
+// Description implements list.DefaultItem.
+func (i RefItem) Description() string {
+	return ""
+}
+
+// Short returns the short name of the reference.
+func (i RefItem) Short() string {
+	return i.Reference.Name().Short()
+}
+
+// FilterValue implements list.Item.
+func (i RefItem) FilterValue() string { return i.Short() }
+
+// RefItems is a list of git references.
+type RefItems []RefItem
+
+// Len implements sort.Interface.
+func (cl RefItems) Len() int { return len(cl) }
+
+// Swap implements sort.Interface.
+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()
+}
+
+// RefItemDelegate is the delegate for the ref item.
+type RefItemDelegate struct {
+	common *common.Common
+}
+
+// Height implements list.ItemDelegate.
+func (d RefItemDelegate) Height() int { return 1 }
+
+// Spacing implements list.ItemDelegate.
+func (d RefItemDelegate) Spacing() int { return 0 }
+
+// Update implements list.ItemDelegate.
+func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(RefItem)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			d.common.Copy.Copy(item.Title())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
+
+// Render implements list.ItemDelegate.
+func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	s := d.common.Styles
+	i, ok := listItem.(RefItem)
+	if !ok {
+		return
+	}
+
+	ref := i.Short()
+	ref = s.RefItemBranch.Render(ref)
+	if i.Reference.IsTag() {
+		ref = s.RefItemTag.Render(ref)
+	}
+	refMaxWidth := m.Width() -
+		s.RefItemSelector.GetMarginLeft() -
+		s.RefItemSelector.GetWidth() -
+		s.RefItemInactive.GetMarginLeft()
+	ref = common.TruncateString(ref, refMaxWidth)
+	refStyle := s.RefItemInactive
+	selector := s.RefItemSelector.Render(" ")
+	if index == m.Index() {
+		selector = s.RefItemSelector.Render(">")
+		refStyle = s.RefItemActive
+	}
+	ref = refStyle.Render(ref)
+	fmt.Fprint(w, selector, ref)
+}

ui/pages/repo/repo.go 🔗

@@ -0,0 +1,345 @@
+package repo
+
+import (
+	"fmt"
+
+	"github.com/charmbracelet/bubbles/help"
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/spinner"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/config"
+	ggit "github.com/charmbracelet/soft-serve/git"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/statusbar"
+	"github.com/charmbracelet/soft-serve/ui/components/tabs"
+	"github.com/charmbracelet/soft-serve/ui/git"
+)
+
+type state int
+
+const (
+	loadingState state = iota
+	loadedState
+)
+
+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]
+}
+
+// UpdateStatusBarMsg updates the status bar.
+type UpdateStatusBarMsg struct{}
+
+// RepoMsg is a message that contains a git.Repository.
+type RepoMsg git.GitRepo
+
+// RefMsg is a message that contains a git.Reference.
+type RefMsg *ggit.Reference
+
+// Repo is a view for a git repository.
+type Repo struct {
+	common       common.Common
+	cfg          *config.Config
+	selectedRepo git.GitRepo
+	activeTab    tab
+	tabs         *tabs.Tabs
+	statusbar    *statusbar.StatusBar
+	boxes        []common.Component
+	ref          *ggit.Reference
+}
+
+// New returns a new Repo.
+func New(cfg *config.Config, c common.Common) *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()
+	}
+	tb := tabs.New(c, ts)
+	readme := NewReadme(c)
+	log := NewLog(c)
+	files := NewFiles(c)
+	branches := NewRefs(c, ggit.RefsHeads)
+	tags := NewRefs(c, ggit.RefsTags)
+	// Make sure the order matches the order of tab constants above.
+	boxes := []common.Component{
+		readme,
+		files,
+		log,
+		branches,
+		tags,
+	}
+	r := &Repo{
+		cfg:       cfg,
+		common:    c,
+		tabs:      tb,
+		statusbar: sb,
+		boxes:     boxes,
+	}
+	return r
+}
+
+// SetSize implements common.Component.
+func (r *Repo) SetSize(width, height int) {
+	r.common.SetSize(width, height)
+	hm := r.common.Styles.RepoBody.GetVerticalFrameSize() +
+		r.common.Styles.RepoHeader.GetHeight() +
+		r.common.Styles.RepoHeader.GetVerticalFrameSize() +
+		r.common.Styles.StatusBar.GetHeight() +
+		r.common.Styles.Tabs.GetHeight() +
+		r.common.Styles.Tabs.GetVerticalFrameSize()
+	r.tabs.SetSize(width, height-hm)
+	r.statusbar.SetSize(width, height-hm)
+	for _, b := range r.boxes {
+		b.SetSize(width, height-hm)
+	}
+}
+
+func (r *Repo) commonHelp() []key.Binding {
+	b := make([]key.Binding, 0)
+	back := r.common.KeyMap.Back
+	back.SetHelp("esc", "back to menu")
+	tab := r.common.KeyMap.Section
+	tab.SetHelp("tab", "switch tab")
+	b = append(b, back)
+	b = append(b, tab)
+	return b
+}
+
+// ShortHelp implements help.KeyMap.
+func (r *Repo) ShortHelp() []key.Binding {
+	b := r.commonHelp()
+	b = append(b, r.boxes[r.activeTab].(help.KeyMap).ShortHelp()...)
+	return b
+}
+
+// FullHelp implements help.KeyMap.
+func (r *Repo) FullHelp() [][]key.Binding {
+	b := make([][]key.Binding, 0)
+	b = append(b, r.commonHelp())
+	b = append(b, r.boxes[r.activeTab].(help.KeyMap).FullHelp()...)
+	return b
+}
+
+// Init implements tea.View.
+func (r *Repo) Init() tea.Cmd {
+	return tea.Batch(
+		r.tabs.Init(),
+		r.statusbar.Init(),
+	)
+}
+
+// Update implements tea.Model.
+func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case RepoMsg:
+		r.activeTab = 0
+		r.selectedRepo = git.GitRepo(msg)
+		cmds = append(cmds,
+			r.tabs.Init(),
+			r.updateRefCmd,
+			r.updateModels(msg),
+		)
+	case RefMsg:
+		r.ref = msg
+		for _, b := range r.boxes {
+			cmds = append(cmds, b.Init())
+		}
+		cmds = append(cmds,
+			r.updateStatusBarCmd,
+			r.updateModels(msg),
+		)
+	case tabs.SelectTabMsg:
+		r.activeTab = tab(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,
+			)
+		}
+	case tea.KeyMsg, tea.MouseMsg:
+		t, cmd := r.tabs.Update(msg)
+		r.tabs = t.(*tabs.Tabs)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		if r.selectedRepo != nil {
+			cmds = append(cmds, r.updateStatusBarCmd)
+		}
+	case ReadmeMsg:
+	case FileItemsMsg:
+		f, cmd := r.boxes[filesTab].Update(msg)
+		r.boxes[filesTab] = f.(*Files)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	// The Log bubble is the only bubble that uses a spinner, so this is fine
+	// for now. We need to pass the TickMsg to the Log bubble when the Log is
+	// loading but not the current selected tab so that the spinner works.
+	case LogCountMsg, LogItemsMsg, spinner.TickMsg:
+		l, cmd := r.boxes[commitsTab].Update(msg)
+		r.boxes[commitsTab] = l.(*Log)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case RefItemsMsg:
+		switch msg.prefix {
+		case ggit.RefsHeads:
+			b, cmd := r.boxes[branchesTab].Update(msg)
+			r.boxes[branchesTab] = b.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		case ggit.RefsTags:
+			t, cmd := r.boxes[tagsTab].Update(msg)
+			r.boxes[tagsTab] = t.(*Refs)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case UpdateStatusBarMsg:
+		cmds = append(cmds, r.updateStatusBarCmd)
+	case tea.WindowSizeMsg:
+		cmds = append(cmds, r.updateModels(msg))
+	}
+	s, cmd := r.statusbar.Update(msg)
+	r.statusbar = s.(*statusbar.StatusBar)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	m, cmd := r.boxes[r.activeTab].Update(msg)
+	r.boxes[r.activeTab] = m.(common.Component)
+	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.Copy().
+		Width(r.common.Width).
+		Height(r.common.Height)
+	repoBodyStyle := r.common.Styles.RepoBody.Copy()
+	hm := repoBodyStyle.GetVerticalFrameSize() +
+		r.common.Styles.RepoHeader.GetHeight() +
+		r.common.Styles.RepoHeader.GetVerticalFrameSize() +
+		r.common.Styles.StatusBar.GetHeight() +
+		r.common.Styles.Tabs.GetHeight() +
+		r.common.Styles.Tabs.GetVerticalFrameSize()
+	mainStyle := repoBodyStyle.
+		Height(r.common.Height - hm)
+	main := r.boxes[r.activeTab].View()
+	view := lipgloss.JoinVertical(lipgloss.Top,
+		r.headerView(),
+		r.tabs.View(),
+		mainStyle.Render(main),
+		r.statusbar.View(),
+	)
+	return s.Render(view)
+}
+
+func (r *Repo) headerView() string {
+	if r.selectedRepo == nil {
+		return ""
+	}
+	cfg := r.cfg
+	truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
+	name := r.common.Styles.RepoHeaderName.Render(r.selectedRepo.Name())
+	desc := r.selectedRepo.Description()
+	if desc == "" {
+		desc = name
+		name = ""
+	} else {
+		desc = r.common.Styles.RepoHeaderDesc.Render(desc)
+	}
+	// TODO move this into a style.
+	urlStyle := lipgloss.NewStyle().
+		MarginLeft(1).
+		Foreground(lipgloss.Color("168")).
+		Width(r.common.Width - lipgloss.Width(desc) - 1).
+		Align(lipgloss.Right)
+	url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
+	url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
+	url = urlStyle.Render(url)
+	style := r.common.Styles.RepoHeader.Copy().Width(r.common.Width)
+	return style.Render(
+		lipgloss.JoinVertical(lipgloss.Top,
+			truncate.Render(name),
+			truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
+				desc,
+				url,
+			)),
+		),
+	)
+}
+
+func (r *Repo) updateStatusBarCmd() tea.Msg {
+	if r.selectedRepo == nil {
+		return nil
+	}
+	value := r.boxes[r.activeTab].(statusbar.Model).StatusBarValue()
+	info := r.boxes[r.activeTab].(statusbar.Model).StatusBarInfo()
+	ref := ""
+	if r.ref != nil {
+		ref = r.ref.Name().Short()
+	}
+	return statusbar.StatusBarMsg{
+		Key:    r.selectedRepo.Repo(),
+		Value:  value,
+		Info:   info,
+		Branch: fmt.Sprintf("* %s", ref),
+	}
+}
+
+func (r *Repo) updateRefCmd() tea.Msg {
+	if r.selectedRepo == nil {
+		return nil
+	}
+	head, err := r.selectedRepo.HEAD()
+	if err != nil {
+		return common.ErrorMsg(err)
+	}
+	return RefMsg(head)
+}
+
+func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
+	cmds := make([]tea.Cmd, 0)
+	for i, b := range r.boxes {
+		m, cmd := b.Update(msg)
+		r.boxes[i] = m.(common.Component)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+func updateStatusBarCmd() tea.Msg {
+	return UpdateStatusBarMsg{}
+}

ui/pages/selection/item.go 🔗

@@ -0,0 +1,170 @@
+package selection
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/key"
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/git"
+	"github.com/dustin/go-humanize"
+)
+
+// Item represents a single item in the selector.
+type Item struct {
+	repo       git.GitRepo
+	lastUpdate time.Time
+	cmd        string
+	copied     time.Time
+}
+
+// ID implements selector.IdentifiableItem.
+func (i Item) ID() string {
+	return i.repo.Repo()
+}
+
+// Title returns the item title. Implements list.DefaultItem.
+func (i Item) Title() string { return i.repo.Name() }
+
+// Description returns the item description. Implements list.DefaultItem.
+func (i Item) Description() string { return i.repo.Description() }
+
+// FilterValue implements list.Item.
+func (i Item) FilterValue() string { return i.Title() }
+
+// Command returns the item Command view.
+func (i Item) Command() string {
+	return i.cmd
+}
+
+// ItemDelegate is the delegate for the item.
+type ItemDelegate struct {
+	common    *common.Common
+	activeBox *box
+}
+
+// Width returns the item width.
+func (d ItemDelegate) Width() int {
+	width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth()
+	return width
+}
+
+// Height returns the item height. Implements list.ItemDelegate.
+func (d ItemDelegate) Height() int {
+	height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight()
+	return height
+}
+
+// Spacing returns the spacing between items. Implements list.ItemDelegate.
+func (d ItemDelegate) Spacing() int { return 1 }
+
+// Update implements list.ItemDelegate.
+func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	idx := m.Index()
+	item, ok := m.SelectedItem().(Item)
+	if !ok {
+		return nil
+	}
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, d.common.KeyMap.Copy):
+			item.copied = time.Now()
+			d.common.Copy.Copy(item.Command())
+			return m.SetItem(idx, item)
+		}
+	}
+	return nil
+}
+
+// Render implements list.ItemDelegate.
+func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	styles := d.common.Styles
+	i := listItem.(Item)
+	s := strings.Builder{}
+	var matchedRunes []int
+
+	// Conditions
+	var (
+		isSelected = index == m.Index()
+		isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied
+	)
+
+	itemStyle := styles.MenuItem.Copy()
+	if isSelected {
+		itemStyle = itemStyle.Copy().
+			BorderStyle(lipgloss.Border{
+				Left: "┃",
+			}).
+			BorderForeground(styles.ActiveBorderColor)
+		if d.activeBox != nil && *d.activeBox == readmeBox {
+			itemStyle = itemStyle.BorderForeground(styles.InactiveBorderColor)
+		}
+	}
+
+	title := i.Title()
+	title = common.TruncateString(title, m.Width()-itemStyle.GetHorizontalFrameSize())
+	if i.repo.IsPrivate() {
+		title += " 🔒"
+	}
+	if isSelected {
+		title += " "
+	}
+	updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.lastUpdate))
+	if m.Width()-itemStyle.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 {
+		updatedStr = ""
+	}
+	updatedStyle := styles.MenuLastUpdate.Copy().
+		Align(lipgloss.Right).
+		Width(m.Width() - itemStyle.GetHorizontalFrameSize() - lipgloss.Width(title))
+	if isSelected {
+		updatedStyle = updatedStyle.Bold(true)
+	}
+	updated := updatedStyle.Render(updatedStr)
+
+	if isFiltered && index < len(m.VisibleItems()) {
+		// Get indices of matched characters
+		matchedRunes = m.MatchesForItem(index)
+	}
+
+	if isFiltered {
+		unmatched := lipgloss.NewStyle().Inline(true)
+		matched := unmatched.Copy().Underline(true)
+		if isSelected {
+			unmatched = unmatched.Bold(true)
+			matched = matched.Bold(true)
+		}
+		title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched)
+	}
+	titleStyle := lipgloss.NewStyle()
+	if isSelected {
+		titleStyle = titleStyle.Bold(true)
+	}
+	title = titleStyle.Render(title)
+	desc := i.Description()
+	descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243"))
+	if desc == "" {
+		desc = "No description"
+		descStyle = descStyle.Faint(true)
+	}
+	desc = common.TruncateString(desc, m.Width()-itemStyle.GetHorizontalFrameSize())
+	desc = descStyle.Render(desc)
+
+	s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated))
+	s.WriteString("\n")
+	s.WriteString(desc)
+	s.WriteString("\n")
+	cmdStyle := styles.RepoCommand.Copy()
+	cmd := common.TruncateString(i.Command(), m.Width()-itemStyle.GetHorizontalFrameSize())
+	cmd = cmdStyle.Render(cmd)
+	if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) {
+		cmd = cmdStyle.Render("Copied!")
+	}
+	s.WriteString(cmd)
+	fmt.Fprint(w, itemStyle.Render(s.String()))
+}

ui/pages/selection/selection.go 🔗

@@ -0,0 +1,321 @@
+package selection
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/code"
+	"github.com/charmbracelet/soft-serve/ui/components/selector"
+	"github.com/charmbracelet/soft-serve/ui/components/tabs"
+	"github.com/charmbracelet/soft-serve/ui/git"
+	wgit "github.com/charmbracelet/wish/git"
+	"github.com/gliderlabs/ssh"
+)
+
+type box int
+
+const (
+	selectorBox box = iota
+	readmeBox
+	lastBox
+)
+
+func (b box) String() string {
+	return []string{
+		"Repositories",
+		"About",
+	}[b]
+}
+
+// Selection is the model for the selection screen/page.
+type Selection struct {
+	cfg          *config.Config
+	pk           ssh.PublicKey
+	common       common.Common
+	readme       *code.Code
+	readmeHeight int
+	selector     *selector.Selector
+	activeBox    box
+	tabs         *tabs.Tabs
+}
+
+// New creates a new selection model.
+func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
+	ts := make([]string, lastBox)
+	for i, b := range []box{selectorBox, readmeBox} {
+		ts[i] = b.String()
+	}
+	t := tabs.New(common, ts)
+	t.TabSeparator = lipgloss.NewStyle()
+	t.TabInactive = lipgloss.NewStyle().
+		Bold(true).
+		UnsetBackground().
+		Foreground(common.Styles.InactiveBorderColor).
+		Padding(0, 1)
+	t.TabActive = t.TabInactive.Copy().
+		Background(lipgloss.Color("62")).
+		Foreground(lipgloss.Color("230"))
+	sel := &Selection{
+		cfg:       cfg,
+		pk:        pk,
+		common:    common,
+		activeBox: selectorBox, // start with the selector focused
+		tabs:      t,
+	}
+	readme := code.New(common, "", "")
+	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
+	selector := selector.New(common,
+		[]selector.IdentifiableItem{},
+		ItemDelegate{&common, &sel.activeBox})
+	selector.SetShowTitle(false)
+	selector.SetShowHelp(false)
+	selector.SetShowStatusBar(false)
+	selector.DisableQuitKeybindings()
+	sel.selector = selector
+	sel.readme = readme
+	return sel
+}
+
+func (s *Selection) getMargins() (wm, hm int) {
+	wm = 0
+	hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
+		s.common.Styles.Tabs.GetHeight() +
+		2 // tabs margin see View()
+	switch s.activeBox {
+	case selectorBox:
+		hm += s.common.Styles.SelectorBox.GetVerticalFrameSize() +
+			s.common.Styles.SelectorBox.GetHeight()
+	case readmeBox:
+		hm += s.common.Styles.ReadmeBox.GetVerticalFrameSize() +
+			s.common.Styles.ReadmeBox.GetHeight() +
+			1 // readme statusbar
+	}
+	return
+}
+
+// SetSize implements common.Component.
+func (s *Selection) SetSize(width, height int) {
+	s.common.SetSize(width, height)
+	wm, hm := s.getMargins()
+	s.tabs.SetSize(width, height-hm)
+	s.selector.SetSize(width-wm, height-hm)
+	s.readme.SetSize(width-wm, height-hm)
+}
+
+// ShortHelp implements help.KeyMap.
+func (s *Selection) ShortHelp() []key.Binding {
+	k := s.selector.KeyMap
+	kb := make([]key.Binding, 0)
+	kb = append(kb,
+		s.common.KeyMap.UpDown,
+		s.common.KeyMap.Section,
+	)
+	if s.activeBox == selectorBox {
+		copyKey := s.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy command")
+		kb = append(kb,
+			s.common.KeyMap.Select,
+			k.Filter,
+			k.ClearFilter,
+			copyKey,
+		)
+	}
+	return kb
+}
+
+// FullHelp implements help.KeyMap.
+func (s *Selection) FullHelp() [][]key.Binding {
+	switch s.activeBox {
+	case readmeBox:
+		k := s.readme.KeyMap
+		return [][]key.Binding{
+			{
+				k.PageDown,
+				k.PageUp,
+			},
+			{
+				k.HalfPageDown,
+				k.HalfPageUp,
+			},
+			{
+				k.Down,
+				k.Up,
+			},
+		}
+	case selectorBox:
+		copyKey := s.common.KeyMap.Copy
+		copyKey.SetHelp("c", "copy command")
+		k := s.selector.KeyMap
+		return [][]key.Binding{
+			{
+				s.common.KeyMap.Select,
+				copyKey,
+				k.CursorUp,
+				k.CursorDown,
+			},
+			{
+				k.NextPage,
+				k.PrevPage,
+				k.GoToStart,
+				k.GoToEnd,
+			},
+			{
+				k.Filter,
+				k.ClearFilter,
+				k.CancelWhileFiltering,
+				k.AcceptWhileFiltering,
+			},
+		}
+	}
+	return [][]key.Binding{}
+}
+
+// Init implements tea.Model.
+func (s *Selection) Init() tea.Cmd {
+	var readmeCmd tea.Cmd
+	items := make([]selector.IdentifiableItem, 0)
+	cfg := s.cfg
+	pk := s.pk
+	// Put configured repos first
+	for _, r := range cfg.Repos {
+		acc := cfg.AuthRepo(r.Repo, pk)
+		if r.Private && acc < wgit.ReadOnlyAccess {
+			continue
+		}
+		repo, err := cfg.Source.GetRepo(r.Repo)
+		if err != nil {
+			continue
+		}
+		items = append(items, Item{
+			repo: repo,
+			cmd:  git.RepoURL(cfg.Host, cfg.Port, r.Repo),
+		})
+	}
+	for _, r := range cfg.Source.AllRepos() {
+		if r.Repo() == "config" {
+			rm, rp := r.Readme()
+			s.readmeHeight = strings.Count(rm, "\n")
+			readmeCmd = s.readme.SetContent(rm, rp)
+		}
+		acc := cfg.AuthRepo(r.Repo(), pk)
+		if r.IsPrivate() && acc < wgit.ReadOnlyAccess {
+			continue
+		}
+		exists := false
+		lc, err := r.Commit("HEAD")
+		if err != nil {
+			return common.ErrorCmd(err)
+		}
+		lastUpdate := lc.Committer.When
+		if lastUpdate.IsZero() {
+			lastUpdate = lc.Author.When
+		}
+		for i, item := range items {
+			item := item.(Item)
+			if item.repo.Repo() == r.Repo() {
+				exists = true
+				item.lastUpdate = lastUpdate
+				items[i] = item
+				break
+			}
+		}
+		if !exists {
+			items = append(items, Item{
+				repo:       r,
+				lastUpdate: lastUpdate,
+				cmd:        git.RepoURL(cfg.Host, cfg.Port, r.Name()),
+			})
+		}
+	}
+	return tea.Batch(
+		s.selector.Init(),
+		s.selector.SetItems(items),
+		readmeCmd,
+	)
+}
+
+// Update implements tea.Model.
+func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		r, cmd := s.readme.Update(msg)
+		s.readme = r.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+		m, cmd := s.selector.Update(msg)
+		s.selector = m.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case tea.KeyMsg, tea.MouseMsg:
+		switch msg := msg.(type) {
+		case tea.KeyMsg:
+			switch {
+			case key.Matches(msg, s.common.KeyMap.Back):
+				cmds = append(cmds, s.selector.Init())
+			}
+		}
+		t, cmd := s.tabs.Update(msg)
+		s.tabs = t.(*tabs.Tabs)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case tabs.ActiveTabMsg:
+		s.activeBox = box(msg)
+	}
+	switch s.activeBox {
+	case readmeBox:
+		r, cmd := s.readme.Update(msg)
+		s.readme = r.(*code.Code)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case selectorBox:
+		m, cmd := s.selector.Update(msg)
+		s.selector = m.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return s, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (s *Selection) View() string {
+	var view string
+	wm, hm := s.getMargins()
+	hm++ // tabs margin
+	switch s.activeBox {
+	case selectorBox:
+		ss := s.common.Styles.SelectorBox.Copy().
+			Width(s.common.Width - wm).
+			Height(s.common.Height - hm)
+		view = ss.Render(s.selector.View())
+	case readmeBox:
+		rs := s.common.Styles.ReadmeBox.Copy().
+			Height(s.common.Height - hm)
+		status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
+		readmeStatus := lipgloss.NewStyle().
+			Align(lipgloss.Right).
+			Width(s.common.Width - wm).
+			Foreground(s.common.Styles.InactiveBorderColor).
+			Render(status)
+		view = rs.Render(lipgloss.JoinVertical(lipgloss.Top,
+			s.readme.View(),
+			readmeStatus,
+		))
+	}
+	ts := s.common.Styles.Tabs.Copy().
+		MarginBottom(1)
+	return lipgloss.JoinVertical(lipgloss.Top,
+		ts.Render(s.tabs.View()),
+		view,
+	)
+}

internal/tui/style/style.go → ui/styles/styles.go 🔗

@@ -1,4 +1,4 @@
-package style
+package styles
 
 import (
 	"github.com/charmbracelet/lipgloss"
@@ -7,7 +7,7 @@ import (
 // XXX: For now, this is in its own package so that it can be shared between
 // different packages without incurring an illegal import cycle.
 
-// Styles defines styles for the TUI.
+// Styles defines styles for the UI.
 type Styles struct {
 	ActiveBorderColor   lipgloss.Color
 	InactiveBorderColor lipgloss.Color
@@ -15,20 +15,28 @@ type Styles struct {
 	App    lipgloss.Style
 	Header lipgloss.Style
 
-	Menu             lipgloss.Style
-	MenuCursor       lipgloss.Style
-	MenuItem         lipgloss.Style
-	SelectedMenuItem lipgloss.Style
+	Menu           lipgloss.Style
+	MenuCursor     lipgloss.Style
+	MenuItem       lipgloss.Style
+	MenuLastUpdate lipgloss.Style
+
+	// Selection page styles
+	SelectorBox lipgloss.Style
+	ReadmeBox   lipgloss.Style
 
 	RepoTitleBorder lipgloss.Border
 	RepoNoteBorder  lipgloss.Border
 	RepoBodyBorder  lipgloss.Border
 
-	RepoTitle    lipgloss.Style
-	RepoTitleBox lipgloss.Style
-	RepoNote     lipgloss.Style
-	RepoNoteBox  lipgloss.Style
-	RepoBody     lipgloss.Style
+	Repo           lipgloss.Style
+	RepoTitle      lipgloss.Style
+	RepoTitleBox   lipgloss.Style
+	RepoCommand    lipgloss.Style
+	RepoNoteBox    lipgloss.Style
+	RepoBody       lipgloss.Style
+	RepoHeader     lipgloss.Style
+	RepoHeaderName lipgloss.Style
+	RepoHeaderDesc lipgloss.Style
 
 	Footer      lipgloss.Style
 	Branch      lipgloss.Style
@@ -42,10 +50,12 @@ type Styles struct {
 
 	AboutNoReadme lipgloss.Style
 
+	LogItem           lipgloss.Style
 	LogItemSelector   lipgloss.Style
 	LogItemActive     lipgloss.Style
 	LogItemInactive   lipgloss.Style
 	LogItemHash       lipgloss.Style
+	LogItemTitle      lipgloss.Style
 	LogCommit         lipgloss.Style
 	LogCommitHash     lipgloss.Style
 	LogCommitAuthor   lipgloss.Style
@@ -73,21 +83,37 @@ type Styles struct {
 	TreeNoItems      lipgloss.Style
 
 	Spinner lipgloss.Style
+
+	CodeNoContent lipgloss.Style
+
+	StatusBar       lipgloss.Style
+	StatusBarKey    lipgloss.Style
+	StatusBarValue  lipgloss.Style
+	StatusBarInfo   lipgloss.Style
+	StatusBarBranch lipgloss.Style
+	StatusBarHelp   lipgloss.Style
+
+	Tabs         lipgloss.Style
+	TabInactive  lipgloss.Style
+	TabActive    lipgloss.Style
+	TabSeparator lipgloss.Style
 }
 
-// DefaultStyles returns default styles for the TUI.
+// DefaultStyles returns default styles for the UI.
 func DefaultStyles() *Styles {
 	s := new(Styles)
 
 	s.ActiveBorderColor = lipgloss.Color("62")
-	s.InactiveBorderColor = lipgloss.Color("236")
+	s.InactiveBorderColor = lipgloss.Color("241")
 
 	s.App = lipgloss.NewStyle().
 		Margin(1, 2)
 
 	s.Header = lipgloss.NewStyle().
-		Foreground(lipgloss.Color("62")).
-		Align(lipgloss.Right).
+		Align(lipgloss.Left).
+		Height(1).
+		PaddingLeft(1).
+		MarginBottom(1).
 		Bold(true)
 
 	s.Menu = lipgloss.NewStyle().
@@ -102,11 +128,19 @@ func DefaultStyles() *Styles {
 		SetString(">")
 
 	s.MenuItem = lipgloss.NewStyle().
-		PaddingLeft(2)
+		PaddingLeft(1).
+		Border(lipgloss.Border{
+			Left: " ",
+		}, false, false, false, true).
+		Height(3)
 
-	s.SelectedMenuItem = lipgloss.NewStyle().
-		Foreground(lipgloss.Color("207")).
-		PaddingLeft(1)
+	s.MenuLastUpdate = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("241")).
+		Align(lipgloss.Right)
+
+	s.SelectorBox = lipgloss.NewStyle()
+
+	s.ReadmeBox = lipgloss.NewStyle()
 
 	s.RepoTitleBorder = lipgloss.Border{
 		Top:         "─",
@@ -141,6 +175,8 @@ func DefaultStyles() *Styles {
 		BottomRight: "╯",
 	}
 
+	s.Repo = lipgloss.NewStyle()
+
 	s.RepoTitle = lipgloss.NewStyle().
 		Padding(0, 2)
 
@@ -148,8 +184,7 @@ func DefaultStyles() *Styles {
 		BorderStyle(s.RepoTitleBorder).
 		BorderForeground(s.InactiveBorderColor)
 
-	s.RepoNote = lipgloss.NewStyle().
-		Padding(0, 2).
+	s.RepoCommand = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("168"))
 
 	s.RepoNoteBox = lipgloss.NewStyle().
@@ -161,12 +196,23 @@ func DefaultStyles() *Styles {
 		BorderLeft(false)
 
 	s.RepoBody = lipgloss.NewStyle().
-		BorderStyle(s.RepoBodyBorder).
-		BorderForeground(s.InactiveBorderColor).
-		PaddingRight(1)
+		Margin(1, 0)
+
+	s.RepoHeader = lipgloss.NewStyle().
+		Height(2).
+		Border(lipgloss.NormalBorder(), false, false, true, false).
+		BorderForeground(lipgloss.Color("238"))
+
+	s.RepoHeaderName = lipgloss.NewStyle().
+		Bold(true)
+
+	s.RepoHeaderDesc = lipgloss.NewStyle().
+		Faint(true)
 
 	s.Footer = lipgloss.NewStyle().
-		MarginTop(1)
+		MarginTop(1).
+		Padding(0, 1).
+		Height(1)
 
 	s.Branch = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("203")).
@@ -184,7 +230,7 @@ func DefaultStyles() *Styles {
 		SetString(" • ")
 
 	s.Error = lipgloss.NewStyle().
-		Padding(1)
+		MarginTop(2)
 
 	s.ErrorTitle = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("230")).
@@ -194,8 +240,7 @@ func DefaultStyles() *Styles {
 
 	s.ErrorBody = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("252")).
-		MarginLeft(2).
-		Width(52) // for now
+		MarginLeft(2)
 
 	s.AboutNoReadme = lipgloss.NewStyle().
 		MarginTop(1).
@@ -203,25 +248,32 @@ func DefaultStyles() *Styles {
 		Foreground(lipgloss.Color("#626262"))
 
 	s.LogItemInactive = lipgloss.NewStyle().
-		MarginLeft(1)
+		Border(lipgloss.Border{
+			Left: " ",
+		}, false, false, false, true).
+		PaddingLeft(1)
+
+	s.LogItemActive = s.LogItemInactive.Copy().
+		Border(lipgloss.Border{
+			Left: "┃",
+		}, false, false, false, true).
+		BorderForeground(lipgloss.Color("#B083EA"))
 
 	s.LogItemSelector = s.LogItemInactive.Copy().
 		Width(1).
 		Foreground(lipgloss.Color("#B083EA"))
 
-	s.LogItemActive = s.LogItemInactive.Copy().
-		Bold(true)
-
 	s.LogItemHash = s.LogItemInactive.Copy().
-		Width(7).
 		Foreground(lipgloss.Color("#A3A322"))
 
+	s.LogItemTitle = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#B083EA"))
+
 	s.LogCommit = lipgloss.NewStyle().
 		Margin(0, 2)
 
-	s.LogCommitHash = s.LogItemHash.Copy().
-		UnsetMarginLeft().
-		UnsetWidth().
+	s.LogCommitHash = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#A3A322")).
 		Bold(true)
 
 	s.LogCommitBody = lipgloss.NewStyle().
@@ -240,11 +292,16 @@ func DefaultStyles() *Styles {
 		Margin(0).
 		Align(lipgloss.Center)
 
-	s.RefItemSelector = s.LogItemSelector.Copy()
+	s.RefItemInactive = lipgloss.NewStyle().
+		MarginLeft(1)
 
-	s.RefItemActive = s.LogItemActive.Copy()
+	s.RefItemSelector = lipgloss.NewStyle().
+		Width(1).
+		Foreground(lipgloss.Color("#B083EA"))
 
-	s.RefItemInactive = s.LogItemInactive.Copy()
+	s.RefItemActive = lipgloss.NewStyle().
+		MarginLeft(1).
+		Bold(true)
 
 	s.RefItemBranch = lipgloss.NewStyle()
 
@@ -253,20 +310,24 @@ func DefaultStyles() *Styles {
 
 	s.RefPaginator = s.LogPaginator.Copy()
 
-	s.TreeItemSelector = s.LogItemSelector.Copy()
+	s.TreeItemSelector = s.TreeItemInactive.Copy().
+		Width(1).
+		Foreground(lipgloss.Color("#B083EA"))
 
-	s.TreeItemActive = s.LogItemActive.Copy()
+	s.TreeItemInactive = lipgloss.NewStyle().
+		MarginLeft(1)
 
-	s.TreeItemInactive = s.LogItemInactive.Copy()
+	s.TreeItemActive = s.TreeItemInactive.Copy().
+		Bold(true)
 
 	s.TreeFileDir = lipgloss.NewStyle().
 		Foreground(lipgloss.Color("#00AAFF"))
 
-	s.TreeFileMode = s.LogItemInactive.Copy().
+	s.TreeFileMode = s.TreeItemInactive.Copy().
 		Width(10).
 		Foreground(lipgloss.Color("#777777"))
 
-	s.TreeFileSize = s.LogItemInactive.Copy().
+	s.TreeFileSize = s.TreeItemInactive.Copy().
 		Foreground(lipgloss.Color("252"))
 
 	s.TreeFileContent = lipgloss.NewStyle()
@@ -280,5 +341,53 @@ func DefaultStyles() *Styles {
 		MarginLeft(2).
 		Foreground(lipgloss.Color("205"))
 
+	s.CodeNoContent = lipgloss.NewStyle().
+		SetString("No Content.").
+		MarginTop(1).
+		MarginLeft(2).
+		Foreground(lipgloss.Color("#626262"))
+
+	s.StatusBar = lipgloss.NewStyle().
+		Height(1)
+
+	s.StatusBarKey = lipgloss.NewStyle().
+		Bold(true).
+		Padding(0, 1).
+		Background(lipgloss.Color("#FF5FD2")).
+		Foreground(lipgloss.Color("#FFFF87"))
+
+	s.StatusBarValue = lipgloss.NewStyle().
+		Padding(0, 1).
+		Background(lipgloss.Color("235")).
+		Foreground(lipgloss.Color("243"))
+
+	s.StatusBarInfo = lipgloss.NewStyle().
+		Padding(0, 1).
+		Background(lipgloss.Color("#FF8EC7")).
+		Foreground(lipgloss.Color("#F1F1F1"))
+
+	s.StatusBarBranch = lipgloss.NewStyle().
+		Padding(0, 1).
+		Background(lipgloss.Color("#6E6ED8")).
+		Foreground(lipgloss.Color("#F1F1F1"))
+
+	s.StatusBarHelp = lipgloss.NewStyle().
+		Padding(0, 1).
+		Background(lipgloss.Color("237")).
+		Foreground(lipgloss.Color("243"))
+
+	s.Tabs = lipgloss.NewStyle()
+
+	s.TabInactive = lipgloss.NewStyle()
+
+	s.TabActive = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("#6E6ED8")).
+		Underline(true)
+
+	s.TabSeparator = lipgloss.NewStyle().
+		SetString("│").
+		Padding(0, 1).
+		Foreground(lipgloss.Color("238"))
+
 	return s
 }

ui/ui.go 🔗

@@ -0,0 +1,302 @@
+package ui
+
+import (
+	"log"
+	"os"
+
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+	"github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/footer"
+	"github.com/charmbracelet/soft-serve/ui/components/header"
+	"github.com/charmbracelet/soft-serve/ui/components/selector"
+	"github.com/charmbracelet/soft-serve/ui/git"
+	"github.com/charmbracelet/soft-serve/ui/pages/repo"
+	"github.com/charmbracelet/soft-serve/ui/pages/selection"
+	"github.com/gliderlabs/ssh"
+)
+
+type page int
+
+const (
+	selectionPage page = iota
+	repoPage
+)
+
+type sessionState int
+
+const (
+	startState sessionState = iota
+	errorState
+	loadedState
+)
+
+// UI is the main UI model.
+type UI struct {
+	cfg         *config.Config
+	session     ssh.Session
+	rs          git.GitRepoSource
+	initialRepo string
+	common      common.Common
+	pages       []common.Component
+	activePage  page
+	state       sessionState
+	header      *header.Header
+	footer      *footer.Footer
+	showFooter  bool
+	error       error
+}
+
+// New returns a new UI model.
+func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI {
+	src := &source{cfg.Source}
+	h := header.New(c, cfg.Name)
+	ui := &UI{
+		cfg:         cfg,
+		session:     s,
+		rs:          src,
+		common:      c,
+		pages:       make([]common.Component, 2), // selection & repo
+		activePage:  selectionPage,
+		state:       startState,
+		header:      h,
+		initialRepo: initialRepo,
+		showFooter:  true,
+	}
+	ui.footer = footer.New(c, ui)
+	return ui
+}
+
+func (ui *UI) getMargins() (wm, hm int) {
+	style := ui.common.Styles.App.Copy()
+	switch ui.activePage {
+	case selectionPage:
+		hm += ui.common.Styles.Header.GetHeight() +
+			ui.common.Styles.Header.GetVerticalFrameSize()
+	case repoPage:
+	}
+	wm += style.GetHorizontalFrameSize()
+	hm += style.GetVerticalFrameSize()
+	if ui.showFooter {
+		// NOTE: we don't use the footer's style to determine the margins
+		// because footer.Height() is the height of the footer after applying
+		// the styles.
+		hm += ui.footer.Height()
+	}
+	return
+}
+
+// ShortHelp implements help.KeyMap.
+func (ui *UI) ShortHelp() []key.Binding {
+	b := make([]key.Binding, 0)
+	switch ui.state {
+	case errorState:
+		b = append(b, ui.common.KeyMap.Back)
+	case loadedState:
+		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
+	}
+	b = append(b,
+		ui.common.KeyMap.Quit,
+		ui.common.KeyMap.Help,
+	)
+	return b
+}
+
+// FullHelp implements help.KeyMap.
+func (ui *UI) FullHelp() [][]key.Binding {
+	b := make([][]key.Binding, 0)
+	switch ui.state {
+	case errorState:
+		b = append(b, []key.Binding{ui.common.KeyMap.Back})
+	case loadedState:
+		b = append(b, ui.pages[ui.activePage].FullHelp()...)
+	}
+	b = append(b, []key.Binding{
+		ui.common.KeyMap.Quit,
+		ui.common.KeyMap.Help,
+	})
+	return b
+}
+
+// SetSize implements common.Component.
+func (ui *UI) SetSize(width, height int) {
+	ui.common.SetSize(width, height)
+	wm, hm := ui.getMargins()
+	ui.header.SetSize(width-wm, height-hm)
+	ui.footer.SetSize(width-wm, height-hm)
+	for _, p := range ui.pages {
+		if p != nil {
+			p.SetSize(width-wm, height-hm)
+		}
+	}
+}
+
+// Init implements tea.Model.
+func (ui *UI) Init() tea.Cmd {
+	ui.pages[selectionPage] = selection.New(
+		ui.cfg,
+		ui.session.PublicKey(),
+		ui.common,
+	)
+	ui.pages[repoPage] = repo.New(
+		ui.cfg,
+		ui.common,
+	)
+	ui.SetSize(ui.common.Width, ui.common.Height)
+	cmds := make([]tea.Cmd, 0)
+	cmds = append(cmds,
+		ui.pages[selectionPage].Init(),
+		ui.pages[repoPage].Init(),
+	)
+	if ui.initialRepo != "" {
+		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
+	}
+	ui.state = loadedState
+	ui.SetSize(ui.common.Width, ui.common.Height)
+	return tea.Batch(cmds...)
+}
+
+// Update implements tea.Model.
+func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	if os.Getenv("DEBUG") == "true" {
+		log.Printf("ui msg: %T", msg)
+	}
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		ui.SetSize(msg.Width, msg.Height)
+		for i, p := range ui.pages {
+			m, cmd := p.Update(msg)
+			ui.pages[i] = m.(common.Component)
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	case tea.KeyMsg, tea.MouseMsg:
+		switch msg := msg.(type) {
+		case tea.KeyMsg:
+			switch {
+			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
+				ui.error = nil
+				ui.state = loadedState
+				// Always show the footer on error.
+				ui.showFooter = ui.footer.ShowAll()
+			case key.Matches(msg, ui.common.KeyMap.Help):
+				ui.footer.SetShowAll(!ui.footer.ShowAll())
+				// Show the footer when on repo page and shot all help.
+				if ui.error == nil && ui.activePage == repoPage {
+					ui.showFooter = !ui.showFooter
+				}
+			case key.Matches(msg, ui.common.KeyMap.Quit):
+				return ui, tea.Quit
+			case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
+				ui.activePage = selectionPage
+				// Always show the footer on selection page.
+				ui.showFooter = true
+			}
+		}
+	case repo.RepoMsg:
+		ui.activePage = repoPage
+		// Show the footer on repo page if show all is set.
+		ui.showFooter = ui.footer.ShowAll()
+	case common.ErrorMsg:
+		ui.error = msg
+		ui.state = errorState
+		ui.showFooter = true
+		return ui, nil
+	case selector.SelectMsg:
+		switch msg.IdentifiableItem.(type) {
+		case selection.Item:
+			if ui.activePage == selectionPage {
+				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
+			}
+		}
+	}
+	h, cmd := ui.header.Update(msg)
+	ui.header = h.(*header.Header)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	f, cmd := ui.footer.Update(msg)
+	ui.footer = f.(*footer.Footer)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	if ui.state == loadedState {
+		m, cmd := ui.pages[ui.activePage].Update(msg)
+		ui.pages[ui.activePage] = m.(common.Component)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	// This fixes determining the height margin of the footer.
+	ui.SetSize(ui.common.Width, ui.common.Height)
+	return ui, tea.Batch(cmds...)
+}
+
+// View implements tea.Model.
+func (ui *UI) View() string {
+	var view string
+	wm, hm := ui.getMargins()
+	style := ui.common.Styles.App.Copy()
+	switch ui.state {
+	case startState:
+		view = "Loading..."
+	case errorState:
+		err := ui.common.Styles.ErrorTitle.Render("Bummer")
+		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
+		view = ui.common.Styles.Error.Copy().
+			Width(ui.common.Width -
+				wm -
+				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
+			Height(ui.common.Height -
+				hm -
+				ui.common.Styles.Error.GetVerticalFrameSize()).
+			Render(err)
+	case loadedState:
+		view = ui.pages[ui.activePage].View()
+	default:
+		view = "Unknown state :/ this is a bug!"
+	}
+	switch ui.activePage {
+	case selectionPage:
+		view = lipgloss.JoinVertical(lipgloss.Bottom,
+			ui.header.View(),
+			view,
+		)
+	case repoPage:
+	}
+	if ui.showFooter {
+		view = lipgloss.JoinVertical(lipgloss.Bottom,
+			view,
+			ui.footer.View(),
+		)
+	}
+	return style.Render(
+		view,
+	)
+}
+
+func (ui *UI) setRepoCmd(rn string) tea.Cmd {
+	return func() tea.Msg {
+		for _, r := range ui.rs.AllRepos() {
+			if r.Repo() == rn {
+				return repo.RepoMsg(r)
+			}
+		}
+		return common.ErrorMsg(git.ErrMissingRepo)
+	}
+}
+
+func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
+	return func() tea.Msg {
+		for _, r := range ui.rs.AllRepos() {
+			if r.Repo() == rn {
+				return repo.RepoMsg(r)
+			}
+		}
+		return nil
+	}
+}