diff --git a/cmd/soft/main.go b/cmd/soft/main.go index bb0954136e17dcc8628417098098f60677dc683a..b6a0ea332836f24aa9afc5845adfd68d37dea736 100644 --- a/cmd/soft/main.go +++ b/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 ( diff --git a/config/config.go b/config/config.go index dc60e2dad1f2cd802a98d1ca3894c6db4bc0d8ba..e84e1c3c0ff81e8486597f068f99ea0239b10d59 100644 --- a/config/config.go +++ b/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 } diff --git a/config/config_test.go b/config/config_test.go index 6a8eb7e4890f4ff535b7523491936a6273590755..12ddd8c244a7a556f1498dace6b7da61a1e5d706 100644 --- a/config/config_test.go +++ b/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 } diff --git a/internal/config/defaults.go b/config/defaults.go similarity index 100% rename from internal/config/defaults.go rename to config/defaults.go diff --git a/internal/config/git.go b/config/git.go similarity index 100% rename from internal/config/git.go rename to config/git.go diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 86cbc52b734dc1281c6ed0f6debc59f5523aa2cf..0000000000000000000000000000000000000000 --- a/internal/config/config.go +++ /dev/null @@ -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 -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go deleted file mode 100644 index 724cb5c8329588a1c68a7569c06c5e9d36b2383a..0000000000000000000000000000000000000000 --- a/internal/config/config_test.go +++ /dev/null @@ -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 -} diff --git a/internal/config/testdata/k1.pub b/internal/config/testdata/k1.pub deleted file mode 100644 index d82e29394d343e6e36bc1759b06689a399ea80a4..0000000000000000000000000000000000000000 --- a/internal/config/testdata/k1.pub +++ /dev/null @@ -1 +0,0 @@ -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b diff --git a/internal/git/git.go b/internal/git/git.go deleted file mode 100644 index 89762d524c558aecdf42552c0e75fb20a0abb7a7..0000000000000000000000000000000000000000 --- a/internal/git/git.go +++ /dev/null @@ -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() -} diff --git a/internal/tui/bubble.go b/internal/tui/bubble.go deleted file mode 100644 index 436dbc4db92ec62b0a84c49478aa9da1260dd5e4..0000000000000000000000000000000000000000 --- a/internal/tui/bubble.go +++ /dev/null @@ -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)) -} diff --git a/internal/tui/bubbles/repo/bubble.go b/internal/tui/bubbles/repo/bubble.go deleted file mode 100644 index 6e9a34a99a99db365955758f5ae776f1dbfbdd22..0000000000000000000000000000000000000000 --- a/internal/tui/bubbles/repo/bubble.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/commands.go b/internal/tui/commands.go deleted file mode 100644 index 64c1ff4851dc3ec81f41babc2a758be065b600d3..0000000000000000000000000000000000000000 --- a/internal/tui/commands.go +++ /dev/null @@ -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 -} diff --git a/internal/tui/session.go b/internal/tui/session.go deleted file mode 100644 index 7501769e1c752703dd0b4d6e4e1779f5b3688bd9..0000000000000000000000000000000000000000 --- a/internal/tui/session.go +++ /dev/null @@ -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(), - } - } -} diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index 157a1c2bbbe82fd3f0d3b398e2050fe4c4281e20..6cd371be928c55dcc655f98b5f4c5963a84b3fef 100644 --- a/server/cmd/cmd.go +++ b/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" ) diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..dc60e2dad1f2cd802a98d1ca3894c6db4bc0d8ba --- /dev/null +++ b/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 +} diff --git a/server/config/config_test.go b/server/config/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6a8eb7e4890f4ff535b7523491936a6273590755 --- /dev/null +++ b/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", + }) +} diff --git a/server/middleware.go b/server/middleware.go index c45cfad4f61af95b5237d69b62547c8150911fec..ce3d3df9c0af3c6c33f50732415dea0bb78b1c00 100644 --- a/server/middleware.go +++ b/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" diff --git a/server/middleware_test.go b/server/middleware_test.go index 94ac3ebbfef8dd25bbdcf392ec31e48f4f6301f4..6895d3d10d9483fb0b202973a84e388a5f8a6bc8 100644 --- a/server/middleware_test.go +++ b/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" diff --git a/server/server.go b/server/server.go index 708aa3e2fa0c1b261ddc6456f7569e143b5e45eb..a2cf36e5258e2e900450fd331a929e1d9f02fa8e 100644 --- a/server/server.go +++ b/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), ), } diff --git a/server/server_test.go b/server/server_test.go index 0756e7f53d6ea1c9e8c5bf382c6de4d5ad71802c..d7dcf9b8b21cedaba940b88c709c172b94bd9bfc 100644 --- a/server/server_test.go +++ b/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" diff --git a/server/session.go b/server/session.go new file mode 100644 index 0000000000000000000000000000000000000000..1a040fb181f600d1a66a4ac9da133e598fc26da5 --- /dev/null +++ b/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 + } +} diff --git a/ui/components/yankable/yankable.go b/ui/components/yankable/yankable.go new file mode 100644 index 0000000000000000000000000000000000000000..6c3190191136e7bd870aa1d0932f287a43886b9a --- /dev/null +++ b/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) +} diff --git a/ui/keymap/keymap.go b/ui/keymap/keymap.go new file mode 100644 index 0000000000000000000000000000000000000000..859ec32192df0073051dc32ff629db2b674a03d7 --- /dev/null +++ b/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 +} diff --git a/internal/tui/bubbles/selection/bubble.go b/ui/pages/selection/bubble.go similarity index 100% rename from internal/tui/bubbles/selection/bubble.go rename to ui/pages/selection/bubble.go diff --git a/internal/tui/style/style.go b/ui/styles/styles.go similarity index 98% rename from internal/tui/style/style.go rename to ui/styles/styles.go index 632b91dc3e92046b39f175e4a9f583ea828b3930..8ad308c42ffceb01b5c1e7ca9fda7603d53e9eb2 100644 --- a/internal/tui/style/style.go +++ b/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) diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000000000000000000000000000000000000..018a47cb1a44c3e31e404885f2bbd8dac643e96d --- /dev/null +++ b/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 "" +}