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 (
Ayman Bagabas created
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(-)
@@ -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 (
@@ -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
}
@@ -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
}
@@ -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
-}
@@ -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
-}
@@ -1 +0,0 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b
@@ -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()
-}
@@ -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))
-}
@@ -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)
-}
@@ -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
-}
@@ -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(),
- }
- }
-}
@@ -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"
)
@@ -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
+}
@@ -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",
+ })
+}
@@ -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"
@@ -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"
@@ -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),
),
}
@@ -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"
@@ -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
+ }
+}
@@ -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)
+}
@@ -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
+}
@@ -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)
@@ -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 ""
+}