-
Notifications
You must be signed in to change notification settings - Fork 145
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
wip: new ui wip: selector wip: header & footer wip: selection readme wip: add readme wip: copy text wip: fix readme wrap wip: fix errorview margin wip: underline filtered items wip: add selector mouse wheel support wip: fix reset formatting on line wraps wip: initial repo ui implementation wip: repo header go mod tidy add git branch symbol fix: selector keymap feat: display repo commits feat: display commit diffs godox fix config tests feat: render error in app style feat: repo files tab feat: add refs component fix: glitches & add refs statusbar feat: add full help toggle fix: error view height wip init ui and check for errors fix: initial repo fix: url style fix: selecting repo feat: new log item style feat: copy over ssh clean feat: detect private repos feat: indicate private repos via emoji feat: only show private repos for admins feat: add files content line number fix: bold selected file size fix: remove header from repo page fix: comitter & authored name highlight fix: repo last updated time feat: add statusbar symbols fix: no repos fix: prevent tab out of bound error fix: decrease help columns fix: crooked ui size rendering on startup fix: various improvements * don't add line numbers to markdown files * fix selection active item styles * fix selection readme styles * add selection description styles fix: add footer padding fix: move repo readme into its own model * fix refs switch flickering fix: selection item truncate string fix: footer padding feat: repos list title & styles fix: server cli interface fix: simplify ui session struct feat: selection tabs redesign fix: no reference nil deref error feat: add log commits loading fix: replace tabs with space to avoid breaking files line wrapping clean fix(ui): selection box position when no repos fix: log status bar after loading fix: use actual repo name in status bar fix(ui): styling after line breaks fix(ui): subtle ui changes * hide help from repo page * file sizes now appear on the right side fix(ui): show "no description" when a repo doesn't have one fix(ui): glamour line wrapping fix(ui): show footer on error fix(ui): truncate repo clone cmd fix(ui): truncate git clone command on repo page fix(ui): truncate strings on small terminal width fix(ui): switch to files tab on window resize fix(examples): setuid imports clean fix: layout misc changes fix: wrong dockerfile background highlight fix(ui): styles on light terminal backgrounds fix: don't use nerdfonts symbols fix(git): cache head commits fix(ui): loading commits spinner text clear log selected commit after going back to commits log view fix: respect private repos Fixes: #81 feat: update deps
- Loading branch information
1 parent
a1f5bb1
commit ffaa007
Showing
68 changed files
with
4,656 additions
and
2,503 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,53 +1,279 @@ | ||
package config | ||
|
||
import ( | ||
"bytes" | ||
"errors" | ||
"io/fs" | ||
"log" | ||
"path/filepath" | ||
"strings" | ||
"sync" | ||
"text/template" | ||
"time" | ||
|
||
"github.com/caarlos0/env/v6" | ||
"golang.org/x/crypto/ssh" | ||
"gopkg.in/yaml.v3" | ||
|
||
"fmt" | ||
"os" | ||
|
||
"github.com/charmbracelet/soft-serve/server/config" | ||
"github.com/go-git/go-billy/v5/memfs" | ||
ggit "github.com/go-git/go-git/v5" | ||
"github.com/go-git/go-git/v5/plumbing/object" | ||
"github.com/go-git/go-git/v5/plumbing/transport" | ||
"github.com/go-git/go-git/v5/storage/memory" | ||
) | ||
|
||
// Callbacks provides an interface that can be used to run callbacks on different events. | ||
type Callbacks interface { | ||
Tui(action string) | ||
Push(repo string) | ||
Fetch(repo string) | ||
// Config is the Soft Serve configuration. | ||
type Config struct { | ||
Name string `yaml:"name"` | ||
Host string `yaml:"host"` | ||
Port int `yaml:"port"` | ||
AnonAccess string `yaml:"anon-access"` | ||
AllowKeyless bool `yaml:"allow-keyless"` | ||
Users []User `yaml:"users"` | ||
Repos []MenuRepo `yaml:"repos"` | ||
Source *RepoSource `yaml:"-"` | ||
Cfg *config.Config `yaml:"-"` | ||
mtx sync.Mutex | ||
} | ||
|
||
// Config is the configuration for Soft Serve. | ||
type Config struct { | ||
BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""` | ||
Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"` | ||
Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"` | ||
KeyPath string `env:"SOFT_SERVE_KEY_PATH"` | ||
RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"` | ||
InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"` | ||
Callbacks Callbacks | ||
ErrorLog *log.Logger | ||
// User contains user-level configuration for a repository. | ||
type User struct { | ||
Name string `yaml:"name"` | ||
Admin bool `yaml:"admin"` | ||
PublicKeys []string `yaml:"public-keys"` | ||
CollabRepos []string `yaml:"collab-repos"` | ||
} | ||
|
||
// DefaultConfig returns a Config with the values populated with the defaults | ||
// or specified environment variables. | ||
func DefaultConfig() *Config { | ||
cfg := &Config{ErrorLog: log.Default()} | ||
if err := env.Parse(cfg); err != nil { | ||
log.Fatalln(err) | ||
// Repo contains repository configuration information. | ||
type MenuRepo struct { | ||
Name string `yaml:"name"` | ||
Repo string `yaml:"repo"` | ||
Note string `yaml:"note"` | ||
Private bool `yaml:"private"` | ||
Readme string `yaml:"readme"` | ||
} | ||
|
||
// NewConfig creates a new internal Config struct. | ||
func NewConfig(cfg *config.Config) (*Config, error) { | ||
var anonAccess string | ||
var yamlUsers string | ||
var displayHost string | ||
host := cfg.Host | ||
port := cfg.Port | ||
|
||
pks := make([]string, 0) | ||
for _, k := range cfg.InitialAdminKeys { | ||
if bts, err := os.ReadFile(k); err == nil { | ||
// pk is a file, set its contents as pk | ||
k = string(bts) | ||
} | ||
var pk = strings.TrimSpace(k) | ||
if pk == "" { | ||
continue | ||
} | ||
// it is a valid ssh key, nothing to do | ||
if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil { | ||
return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err) | ||
} | ||
pks = append(pks, pk) | ||
} | ||
if cfg.KeyPath == "" { | ||
// NB: cross-platform-compatible path | ||
cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519") | ||
|
||
rs := NewRepoSource(cfg.RepoPath) | ||
c := &Config{ | ||
Cfg: cfg, | ||
} | ||
return cfg.WithCallbacks(nil) | ||
c.Host = cfg.Host | ||
c.Port = port | ||
c.Source = rs | ||
if len(pks) == 0 { | ||
anonAccess = "read-write" | ||
} else { | ||
anonAccess = "no-access" | ||
} | ||
if host == "" { | ||
displayHost = "localhost" | ||
} else { | ||
displayHost = host | ||
} | ||
yamlConfig := fmt.Sprintf(defaultConfig, | ||
displayHost, | ||
port, | ||
anonAccess, | ||
len(pks) == 0, | ||
) | ||
if len(pks) == 0 { | ||
yamlUsers = defaultUserConfig | ||
} else { | ||
var result string | ||
for _, pk := range pks { | ||
result += fmt.Sprintf(" - %s\n", pk) | ||
} | ||
yamlUsers = fmt.Sprintf(hasKeyUserConfig, result) | ||
} | ||
yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig) | ||
err := c.createDefaultConfigRepo(yaml) | ||
if err != nil { | ||
return nil, err | ||
} | ||
return c, nil | ||
} | ||
|
||
// WithCallbacks applies the given Callbacks to the configuration. | ||
func (c *Config) WithCallbacks(callbacks Callbacks) *Config { | ||
c.Callbacks = callbacks | ||
return c | ||
// Reload reloads the configuration. | ||
func (cfg *Config) Reload() error { | ||
cfg.mtx.Lock() | ||
defer cfg.mtx.Unlock() | ||
err := cfg.Source.LoadRepos() | ||
if err != nil { | ||
return err | ||
} | ||
cr, err := cfg.Source.GetRepo("config") | ||
if err != nil { | ||
return err | ||
} | ||
cs, _, err := cr.LatestFile("config.yaml") | ||
if err != nil { | ||
return err | ||
} | ||
err = yaml.Unmarshal([]byte(cs), cfg) | ||
if err != nil { | ||
return fmt.Errorf("bad yaml in config.yaml: %s", err) | ||
} | ||
for _, r := range cfg.Source.AllRepos() { | ||
name := r.Name() | ||
err = r.UpdateServerInfo() | ||
if err != nil { | ||
log.Printf("error updating server info for %s: %s", name, err) | ||
} | ||
pat := "README*" | ||
rp := "" | ||
for _, rr := range cfg.Repos { | ||
if name == rr.Repo { | ||
rp = rr.Readme | ||
r.name = rr.Name | ||
r.description = rr.Note | ||
r.private = rr.Private | ||
break | ||
} | ||
} | ||
if rp != "" { | ||
pat = rp | ||
} | ||
rm := "" | ||
fc, fp, _ := r.LatestFile(pat) | ||
rm = fc | ||
if name == "config" { | ||
md, err := templatize(rm, cfg) | ||
if err != nil { | ||
return err | ||
} | ||
rm = md | ||
} | ||
r.SetReadme(rm, fp) | ||
} | ||
return nil | ||
} | ||
|
||
func createFile(path string, content string) error { | ||
f, err := os.Create(path) | ||
if err != nil { | ||
return err | ||
} | ||
defer f.Close() | ||
_, err = f.WriteString(content) | ||
if err != nil { | ||
return err | ||
} | ||
return f.Sync() | ||
} | ||
|
||
func (cfg *Config) createDefaultConfigRepo(yaml string) error { | ||
cn := "config" | ||
rp := filepath.Join(cfg.Cfg.RepoPath, cn) | ||
rs := cfg.Source | ||
err := rs.LoadRepo(cn) | ||
if errors.Is(err, fs.ErrNotExist) { | ||
repo, err := ggit.PlainInit(rp, true) | ||
if err != nil { | ||
return err | ||
} | ||
repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{ | ||
URL: rp, | ||
}) | ||
if err != nil && err != transport.ErrEmptyRemoteRepository { | ||
return err | ||
} | ||
wt, err := repo.Worktree() | ||
if err != nil { | ||
return err | ||
} | ||
rm, err := wt.Filesystem.Create("README.md") | ||
if err != nil { | ||
return err | ||
} | ||
_, err = rm.Write([]byte(defaultReadme)) | ||
if err != nil { | ||
return err | ||
} | ||
_, err = wt.Add("README.md") | ||
if err != nil { | ||
return err | ||
} | ||
cf, err := wt.Filesystem.Create("config.yaml") | ||
if err != nil { | ||
return err | ||
} | ||
_, err = cf.Write([]byte(yaml)) | ||
if err != nil { | ||
return err | ||
} | ||
_, err = wt.Add("config.yaml") | ||
if err != nil { | ||
return err | ||
} | ||
author := object.Signature{ | ||
Name: "Soft Serve Server", | ||
Email: "[email protected]", | ||
When: time.Now(), | ||
} | ||
_, err = wt.Commit("Default init", &ggit.CommitOptions{ | ||
All: true, | ||
Author: &author, | ||
Committer: &author, | ||
}) | ||
if err != nil { | ||
return err | ||
} | ||
err = repo.Push(&ggit.PushOptions{}) | ||
if err != nil { | ||
return err | ||
} | ||
} else if err != nil { | ||
return err | ||
} | ||
return cfg.Reload() | ||
} | ||
|
||
// WithErrorLogger sets the error logger for the configuration. | ||
func (c *Config) WithErrorLogger(logger *log.Logger) *Config { | ||
c.ErrorLog = logger | ||
return c | ||
func (cfg *Config) isPrivate(repo string) bool { | ||
for _, r := range cfg.Repos { | ||
if r.Repo == repo { | ||
return r.Private | ||
} | ||
} | ||
return false | ||
} | ||
|
||
func templatize(mdt string, tmpl interface{}) (string, error) { | ||
t, err := template.New("readme").Parse(mdt) | ||
if err != nil { | ||
return "", err | ||
} | ||
buf := &bytes.Buffer{} | ||
err = t.Execute(buf, tmpl) | ||
if err != nil { | ||
return "", err | ||
} | ||
return buf.String(), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
File renamed without changes.
Oops, something went wrong.