wip: new ui

Ayman Bagabas created

Change summary

cmd/soft/main.go                    |   2 
config/config.go                    | 291 +++++++++++++++++++++++++++---
config/config_test.go               |  34 ++
config/defaults.go                  |   0 
config/git.go                       |   0 
internal/config/config.go           | 277 -----------------------------
internal/config/config_test.go      |  37 ---
internal/config/testdata/k1.pub     |   1 
internal/git/git.go                 | 260 ---------------------------
internal/tui/bubble.go              | 234 ------------------------
internal/tui/bubbles/repo/bubble.go | 137 --------------
internal/tui/commands.go            | 118 ------------
internal/tui/session.go             |  37 ---
server/cmd/cmd.go                   |   2 
server/config/config.go             |  53 +++++
server/config/config_test.go        |  19 ++
server/middleware.go                |   2 
server/middleware_test.go           |   4 
server/server.go                    |   8 
server/server_test.go               |   2 
server/session.go                   |  78 ++++++++
ui/components/yankable/yankable.go  |  44 ++++
ui/keymap/keymap.go                 |  26 ++
ui/pages/selection/bubble.go        |   0 
ui/styles/styles.go                 |   6 
ui/ui.go                            |  49 +++++
26 files changed, 565 insertions(+), 1,156 deletions(-)

Detailed changes

cmd/soft/main.go 🔗

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

config/config.go 🔗

@@ -1,53 +1,276 @@
 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/internal/git"
+	"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        []Repo          `yaml:"repos"`
+	Source       *git.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 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)
 	}
-	if cfg.KeyPath == "" {
-		// NB: cross-platform-compatible path
-		cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
+
+	rs := git.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
+				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,
+		})
+		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/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/git/git.go 🔗

@@ -1,260 +0,0 @@
-package git
-
-import (
-	"errors"
-	"log"
-	"os"
-	"path/filepath"
-	"sync"
-
-	"github.com/charmbracelet/soft-serve/git"
-	"github.com/gobwas/glob"
-	"github.com/golang/groupcache/lru"
-)
-
-// ErrMissingRepo indicates that the requested repository could not be found.
-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
-}
-
-// open opens a Git repository.
-func (rs *RepoSource) open(path string) (*Repo, error) {
-	rg, err := git.Open(path)
-	if err != nil {
-		return nil, err
-	}
-	r := &Repo{
-		path:       path,
-		repository: rg,
-		patchCache: lru.New(1000),
-	}
-	_, err = r.HEAD()
-	if err != nil {
-		return nil, err
-	}
-	_, err = r.References()
-	if err != nil {
-		return nil, err
-	}
-	return r, nil
-}
-
-// 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 {
-	return filepath.Base(r.path)
-}
-
-// Readme returns the readme and its path for the repository.
-func (r *Repo) Readme() (readme string, path string) {
-	return r.readme, r.readmePath
-}
-
-// SetReadme sets the readme for the repository.
-func (r *Repo) SetReadme(readme, path string) {
-	r.readme = readme
-	r.readmePath = path
-}
-
-// HEAD returns the reference for a repository.
-func (r *Repo) HEAD() (*git.Reference, error) {
-	if r.head != nil {
-		return r.head, nil
-	}
-	h, err := r.repository.HEAD()
-	if err != nil {
-		return nil, err
-	}
-	r.head = h
-	return h, nil
-}
-
-// GetReferences returns the references for a repository.
-func (r *Repo) References() ([]*git.Reference, error) {
-	if r.refs != nil {
-		return r.refs, nil
-	}
-	refs, err := r.repository.References()
-	if err != nil {
-		return nil, err
-	}
-	r.refs = refs
-	return refs, nil
-}
-
-// Tree returns the git tree for a given path.
-func (r *Repo) Tree(ref *git.Reference, path string) (*git.Tree, error) {
-	return r.repository.TreePath(ref, path)
-}
-
-// Diff returns the diff for a given commit.
-func (r *Repo) Diff(commit *git.Commit) (*git.Diff, error) {
-	hash := commit.Hash.String()
-	c, ok := r.patchCache.Get(hash)
-	if ok {
-		return c.(*git.Diff), nil
-	}
-	diff, err := r.repository.Diff(commit)
-	if err != nil {
-		return nil, err
-	}
-	r.patchCache.Add(hash, diff)
-	return diff, nil
-}
-
-// CountCommits returns the number of commits for a repository.
-func (r *Repo) CountCommits(ref *git.Reference) (int64, error) {
-	tc, err := r.repository.CountCommits(ref)
-	if err != nil {
-		return 0, err
-	}
-	return tc, 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)
-}
-
-// Push pushes the repository to the remote.
-func (r *Repo) Push(remote, branch string) error {
-	return r.repository.Push(remote, branch)
-}
-
-// RepoSource is a reference to an on-disk repositories.
-type RepoSource struct {
-	Path  string
-	mtx   sync.Mutex
-	repos map[string]*Repo
-}
-
-// NewRepoSource creates a new RepoSource.
-func NewRepoSource(repoPath string) *RepoSource {
-	err := os.MkdirAll(repoPath, os.ModeDir|os.FileMode(0700))
-	if err != nil {
-		log.Fatal(err)
-	}
-	rs := &RepoSource{Path: repoPath}
-	rs.repos = make(map[string]*Repo, 0)
-	return rs
-}
-
-// AllRepos returns all repositories for the given RepoSource.
-func (rs *RepoSource) AllRepos() []*Repo {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	repos := make([]*Repo, 0, len(rs.repos))
-	for _, r := range rs.repos {
-		repos = append(repos, r)
-	}
-	return repos
-}
-
-// GetRepo returns a repository by name.
-func (rs *RepoSource) GetRepo(name string) (*Repo, error) {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	r, ok := rs.repos[name]
-	if !ok {
-		return nil, ErrMissingRepo
-	}
-	return r, nil
-}
-
-// InitRepo initializes a new Git repository.
-func (rs *RepoSource) InitRepo(name string, bare bool) (*Repo, error) {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	rp := filepath.Join(rs.Path, name)
-	rg, err := git.Init(rp, bare)
-	if err != nil {
-		return nil, err
-	}
-	r := &Repo{
-		path:       rp,
-		repository: rg,
-		refs: []*git.Reference{
-			git.NewReference(rp, git.RefsHeads+"master"),
-		},
-	}
-	rs.repos[name] = r
-	return r, nil
-}
-
-// LoadRepo loads a repository from disk.
-func (rs *RepoSource) LoadRepo(name string) error {
-	rs.mtx.Lock()
-	defer rs.mtx.Unlock()
-	rp := filepath.Join(rs.Path, name)
-	r, err := rs.open(rp)
-	if err != nil {
-		return err
-	}
-	rs.repos[name] = r
-	return nil
-}
-
-// LoadRepos opens Git repositories.
-func (rs *RepoSource) LoadRepos() error {
-	rd, err := os.ReadDir(rs.Path)
-	if err != nil {
-		return err
-	}
-	for _, de := range rd {
-		err = rs.LoadRepo(de.Name())
-		if err == git.ErrNotAGitRepository {
-			continue
-		}
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// LatestFile returns the contents of the latest file at the specified path in
-// the repository and its file path.
-func (r *Repo) LatestFile(pattern string) (string, string, error) {
-	g := glob.MustCompile(pattern)
-	dir := filepath.Dir(pattern)
-	t, err := r.repository.TreePath(r.head, dir)
-	if err != nil {
-		return "", "", err
-	}
-	ents, err := t.Entries()
-	if err != nil {
-		return "", "", err
-	}
-	for _, e := range ents {
-		fp := filepath.Join(dir, e.Name())
-		if e.IsTree() {
-			continue
-		}
-		if g.Match(fp) {
-			bts, err := e.Contents()
-			if err != nil {
-				return "", "", err
-			}
-			return string(bts), fp, nil
-		}
-	}
-	return "", "", git.ErrFileNotFound
-}
-
-// UpdateServerInfo updates the server info for the repository.
-func (r *Repo) UpdateServerInfo() error {
-	return r.repository.UpdateServerInfo()
-}

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/commands.go 🔗

@@ -1,118 +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
-		}
-		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/cmd.go 🔗

@@ -3,7 +3,7 @@ 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"
 )

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"

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.
@@ -39,7 +39,7 @@ func NewServer(cfg *config.Config) *Server {
 			cfg.ErrorLog,
 			lm.Middleware(),
 			softMiddleware(ac),
-			bm.Middleware(tui.SessionHandler(ac)),
+			bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256),
 			gm.Middleware(cfg.RepoPath, ac),
 		),
 	}

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,78 @@
+package server
+
+import (
+	"fmt"
+
+	tea "github.com/charmbracelet/bubbletea"
+	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui"
+	bm "github.com/charmbracelet/wish/bubbletea"
+	"github.com/gliderlabs/ssh"
+)
+
+type Session struct {
+	tea.Model
+	*tea.Program
+	ssh.Session
+	Cfg         *appCfg.Config
+	width       int
+	height      int
+	initialRepo string
+}
+
+func (s *Session) Config() *appCfg.Config {
+	return s.Cfg
+}
+
+func (s *Session) Send(msg tea.Msg) {
+	s.Program.Send(msg)
+}
+
+func (s *Session) Width() int {
+	return s.width
+}
+
+func (s *Session) Height() int {
+	return s.height
+}
+
+func (s *Session) InitialRepo() string {
+	return s.initialRepo
+}
+
+func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
+	return func(s ssh.Session) *tea.Program {
+		pty, _, active := s.Pty()
+		if !active {
+			fmt.Println("not active")
+			return nil
+		}
+		sess := &Session{
+			Session:     s,
+			Cfg:         ac,
+			width:       pty.Window.Width,
+			height:      pty.Window.Height,
+			initialRepo: "",
+		}
+		cmd := s.Command()
+		switch len(cmd) {
+		case 0:
+			sess.initialRepo = ""
+		case 1:
+			sess.initialRepo = cmd[0]
+		}
+		if ac.Cfg.Callbacks != nil {
+			ac.Cfg.Callbacks.Tui("new session")
+		}
+		m := ui.New(sess)
+		p := tea.NewProgram(m,
+			tea.WithInput(s),
+			tea.WithOutput(s),
+			tea.WithAltScreen(),
+			tea.WithoutCatchPanics(),
+		)
+		sess.Model = m
+		sess.Program = p
+		return p
+	}
+}

ui/components/yankable/yankable.go 🔗

@@ -0,0 +1,44 @@
+package yankable
+
+import (
+	"time"
+
+	"github.com/charmbracelet/bubbles/timer"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+type Yankable struct {
+	YankStyle lipgloss.Style
+	Style     lipgloss.Style
+	Text      string
+	timer     timer.Model
+	clicked   bool
+}
+
+func (y *Yankable) Init() tea.Cmd {
+	y.timer = timer.New(3 * time.Second)
+	return nil
+}
+
+func (y *Yankable) 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.MouseRight:
+			y.clicked = true
+			cmds = append(cmds, y.timer.Init())
+		}
+	case timer.TimeoutMsg:
+		y.clicked = false
+	}
+	return y, tea.Batch(cmds...)
+}
+
+func (y *Yankable) View() string {
+	if y.clicked {
+		return y.YankStyle.Render(y.Text)
+	}
+	return y.Style.Render(y.Text)
+}

ui/keymap/keymap.go 🔗

@@ -0,0 +1,26 @@
+package keymap
+
+import "github.com/charmbracelet/bubbles/key"
+
+// KeyMap is a map of key bindings for the UI.
+type KeyMap struct {
+	Quit key.Binding
+}
+
+// DefaultKeyMap returns the default key map.
+func DefaultKeyMap() *KeyMap {
+	km := new(KeyMap)
+
+	km.Quit = key.NewBinding(
+		key.WithKeys(
+			"ctrl-c",
+			"q",
+		),
+		key.WithHelp(
+			"q",
+			"quit",
+		),
+	)
+
+	return km
+}

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
@@ -75,7 +75,7 @@ type Styles struct {
 	Spinner lipgloss.Style
 }
 
-// DefaultStyles returns default styles for the TUI.
+// DefaultStyles returns default styles for the UI.
 func DefaultStyles() *Styles {
 	s := new(Styles)
 

ui/ui.go 🔗

@@ -0,0 +1,49 @@
+package ui
+
+import (
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/charmbracelet/soft-serve/ui/keymap"
+)
+
+type Session interface {
+	Send(tea.Msg)
+	Config() *appCfg.Config
+	Width() int
+	Height() int
+	InitialRepo() string
+}
+
+type UI struct {
+	s    Session
+	keys *keymap.KeyMap
+}
+
+func New(s Session) *UI {
+	ui := &UI{
+		s:    s,
+		keys: keymap.DefaultKeyMap(),
+	}
+	return ui
+}
+
+func (ui *UI) Init() tea.Cmd {
+	return nil
+}
+
+func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, ui.keys.Quit):
+			return ui, tea.Quit
+		}
+	}
+	return ui, tea.Batch(cmds...)
+}
+
+func (ui *UI) View() string {
+	return ""
+}