From ffaa00750adb1ec321a5f5107e98c0e7754668c0 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 12 Apr 2022 17:38:35 -0400 Subject: [PATCH] feat(ui): new tui wip: new ui wip: selector wip: header & footer wip: selection readme wip: add readme wip: copy text wip: fix readme wrap wip: fix errorview margin wip: underline filtered items wip: add selector mouse wheel support wip: fix reset formatting on line wraps wip: initial repo ui implementation wip: repo header go mod tidy add git branch symbol fix: selector keymap feat: display repo commits feat: display commit diffs godox fix config tests feat: render error in app style feat: repo files tab feat: add refs component fix: glitches & add refs statusbar feat: add full help toggle fix: error view height wip init ui and check for errors fix: initial repo fix: url style fix: selecting repo feat: new log item style feat: copy over ssh clean feat: detect private repos feat: indicate private repos via emoji feat: only show private repos for admins feat: add files content line number fix: bold selected file size fix: remove header from repo page fix: comitter & authored name highlight fix: repo last updated time feat: add statusbar symbols fix: no repos fix: prevent tab out of bound error fix: decrease help columns fix: crooked ui size rendering on startup fix: various improvements * don't add line numbers to markdown files * fix selection active item styles * fix selection readme styles * add selection description styles fix: add footer padding fix: move repo readme into its own model * fix refs switch flickering fix: selection item truncate string fix: footer padding feat: repos list title & styles fix: server cli interface fix: simplify ui session struct feat: selection tabs redesign fix: no reference nil deref error feat: add log commits loading fix: replace tabs with space to avoid breaking files line wrapping clean fix(ui): selection box position when no repos fix: log status bar after loading fix: use actual repo name in status bar fix(ui): styling after line breaks fix(ui): subtle ui changes * hide help from repo page * file sizes now appear on the right side fix(ui): show "no description" when a repo doesn't have one fix(ui): glamour line wrapping fix(ui): show footer on error fix(ui): truncate repo clone cmd fix(ui): truncate git clone command on repo page fix(ui): truncate strings on small terminal width fix(ui): switch to files tab on window resize fix(examples): setuid imports clean fix: layout misc changes fix: wrong dockerfile background highlight fix(ui): styles on light terminal backgrounds fix: don't use nerdfonts symbols fix(git): cache head commits fix(ui): loading commits spinner text clear log selected commit after going back to commits log view fix: respect private repos Fixes: https://github.com/charmbracelet/soft-serve/issues/81 feat: update deps --- cmd/soft/main.go | 2 +- internal/config/git.go => config/auth.go | 0 config/config.go | 294 +++++++++-- config/config_test.go | 34 +- {internal/config => config}/defaults.go | 0 {internal/git => config}/git.go | 59 ++- {internal/config => config}/testdata/k1.pub | 0 examples/setuid/main.go | 2 +- go.mod | 16 +- go.sum | 34 +- internal/config/config.go | 277 ---------- internal/config/config_test.go | 37 -- internal/tui/bubble.go | 234 --------- internal/tui/bubbles/repo/bubble.go | 137 ----- internal/tui/bubbles/selection/bubble.go | 107 ---- internal/tui/commands.go | 121 ----- internal/tui/session.go | 37 -- server/cmd/cat.go | 10 +- server/cmd/cmd.go | 21 +- server/cmd/git.go | 6 +- server/cmd/list.go | 2 +- server/config/config.go | 53 ++ server/config/config_test.go | 19 + server/middleware.go | 6 +- server/middleware_test.go | 4 +- server/server.go | 10 +- server/server_test.go | 2 +- server/session.go | 57 +++ tui/about/bubble.go | 122 ----- tui/bubble.go | 155 ------ tui/common/consts.go | 28 -- tui/common/error.go | 36 -- tui/common/formatter.go | 88 ---- tui/common/git.go | 16 - tui/common/help.go | 10 - tui/common/reset.go | 7 - tui/common/utils.go | 17 - tui/log/bubble.go | 383 -------------- tui/refs/bubble.go | 185 ------- tui/tree/bubble.go | 341 ------------- tui/viewport/viewport_patch.go | 24 - ui/common/common.go | 22 + ui/common/component.go | 13 + ui/common/error.go | 13 + ui/common/style.go | 19 + ui/common/utils.go | 11 + ui/components/code/code.go | 259 ++++++++++ ui/components/footer/footer.go | 85 ++++ ui/components/header/header.go | 44 ++ ui/components/selector/selector.go | 222 ++++++++ ui/components/statusbar/statusbar.go | 85 ++++ ui/components/tabs/tabs.go | 101 ++++ ui/components/viewport/viewport.go | 97 ++++ ui/git.go | 25 + ui/git/git.go | 42 ++ ui/keymap/keymap.go | 205 ++++++++ ui/pages/repo/files.go | 390 ++++++++++++++ ui/pages/repo/filesitem.go | 146 ++++++ ui/pages/repo/log.go | 476 ++++++++++++++++++ ui/pages/repo/logitem.go | 155 ++++++ ui/pages/repo/readme.go | 114 +++++ ui/pages/repo/refs.go | 196 ++++++++ ui/pages/repo/refsitem.go | 111 ++++ ui/pages/repo/repo.go | 345 +++++++++++++ ui/pages/selection/item.go | 170 +++++++ ui/pages/selection/selection.go | 321 ++++++++++++ .../tui/style/style.go => ui/styles/styles.go | 197 ++++++-- ui/ui.go | 302 +++++++++++ 68 files changed, 4656 insertions(+), 2503 deletions(-) rename internal/config/git.go => config/auth.go (100%) rename {internal/config => config}/defaults.go (100%) rename {internal/git => config}/git.go (83%) rename {internal/config => config}/testdata/k1.pub (100%) delete mode 100644 internal/config/config.go delete mode 100644 internal/config/config_test.go delete mode 100644 internal/tui/bubble.go delete mode 100644 internal/tui/bubbles/repo/bubble.go delete mode 100644 internal/tui/bubbles/selection/bubble.go delete mode 100644 internal/tui/commands.go delete mode 100644 internal/tui/session.go create mode 100644 server/config/config.go create mode 100644 server/config/config_test.go create mode 100644 server/session.go delete mode 100644 tui/about/bubble.go delete mode 100644 tui/bubble.go delete mode 100644 tui/common/consts.go delete mode 100644 tui/common/error.go delete mode 100644 tui/common/formatter.go delete mode 100644 tui/common/git.go delete mode 100644 tui/common/help.go delete mode 100644 tui/common/reset.go delete mode 100644 tui/common/utils.go delete mode 100644 tui/log/bubble.go delete mode 100644 tui/refs/bubble.go delete mode 100644 tui/tree/bubble.go delete mode 100644 tui/viewport/viewport_patch.go create mode 100644 ui/common/common.go create mode 100644 ui/common/component.go create mode 100644 ui/common/error.go create mode 100644 ui/common/style.go create mode 100644 ui/common/utils.go create mode 100644 ui/components/code/code.go create mode 100644 ui/components/footer/footer.go create mode 100644 ui/components/header/header.go create mode 100644 ui/components/selector/selector.go create mode 100644 ui/components/statusbar/statusbar.go create mode 100644 ui/components/tabs/tabs.go create mode 100644 ui/components/viewport/viewport.go create mode 100644 ui/git.go create mode 100644 ui/git/git.go create mode 100644 ui/keymap/keymap.go create mode 100644 ui/pages/repo/files.go create mode 100644 ui/pages/repo/filesitem.go create mode 100644 ui/pages/repo/log.go create mode 100644 ui/pages/repo/logitem.go create mode 100644 ui/pages/repo/readme.go create mode 100644 ui/pages/repo/refs.go create mode 100644 ui/pages/repo/refsitem.go create mode 100644 ui/pages/repo/repo.go create mode 100644 ui/pages/selection/item.go create mode 100644 ui/pages/selection/selection.go rename internal/tui/style/style.go => ui/styles/styles.go (57%) create mode 100644 ui/ui.go diff --git a/cmd/soft/main.go b/cmd/soft/main.go index bb0954136..b6a0ea332 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/internal/config/git.go b/config/auth.go similarity index 100% rename from internal/config/git.go rename to config/auth.go diff --git a/config/config.go b/config/config.go index dc60e2dad..d64fa9fdd 100644 --- a/config/config.go +++ b/config/config.go @@ -1,53 +1,279 @@ package config import ( + "bytes" + "errors" + "io/fs" "log" "path/filepath" + "strings" + "sync" + "text/template" + "time" - "github.com/caarlos0/env/v6" + "golang.org/x/crypto/ssh" + "gopkg.in/yaml.v3" + + "fmt" + "os" + + "github.com/charmbracelet/soft-serve/server/config" + "github.com/go-git/go-billy/v5/memfs" + ggit "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/storage/memory" ) -// Callbacks provides an interface that can be used to run callbacks on different events. -type Callbacks interface { - Tui(action string) - Push(repo string) - Fetch(repo string) +// Config is the Soft Serve configuration. +type Config struct { + Name string `yaml:"name"` + Host string `yaml:"host"` + Port int `yaml:"port"` + AnonAccess string `yaml:"anon-access"` + AllowKeyless bool `yaml:"allow-keyless"` + Users []User `yaml:"users"` + Repos []MenuRepo `yaml:"repos"` + Source *RepoSource `yaml:"-"` + Cfg *config.Config `yaml:"-"` + mtx sync.Mutex } -// Config is the configuration for Soft Serve. -type Config struct { - BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""` - Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"` - Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"` - KeyPath string `env:"SOFT_SERVE_KEY_PATH"` - RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"` - InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"` - Callbacks Callbacks - ErrorLog *log.Logger +// User contains user-level configuration for a repository. +type User struct { + Name string `yaml:"name"` + Admin bool `yaml:"admin"` + PublicKeys []string `yaml:"public-keys"` + CollabRepos []string `yaml:"collab-repos"` } -// DefaultConfig returns a Config with the values populated with the defaults -// or specified environment variables. -func DefaultConfig() *Config { - cfg := &Config{ErrorLog: log.Default()} - if err := env.Parse(cfg); err != nil { - log.Fatalln(err) +// Repo contains repository configuration information. +type MenuRepo struct { + Name string `yaml:"name"` + Repo string `yaml:"repo"` + Note string `yaml:"note"` + Private bool `yaml:"private"` + Readme string `yaml:"readme"` +} + +// NewConfig creates a new internal Config struct. +func NewConfig(cfg *config.Config) (*Config, error) { + var anonAccess string + var yamlUsers string + var displayHost string + host := cfg.Host + port := cfg.Port + + pks := make([]string, 0) + for _, k := range cfg.InitialAdminKeys { + if bts, err := os.ReadFile(k); err == nil { + // pk is a file, set its contents as pk + k = string(bts) + } + var pk = strings.TrimSpace(k) + if pk == "" { + continue + } + // it is a valid ssh key, nothing to do + if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil { + return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err) + } + pks = append(pks, pk) } - if cfg.KeyPath == "" { - // NB: cross-platform-compatible path - cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519") + + rs := NewRepoSource(cfg.RepoPath) + c := &Config{ + Cfg: cfg, } - return cfg.WithCallbacks(nil) + c.Host = cfg.Host + c.Port = port + c.Source = rs + if len(pks) == 0 { + anonAccess = "read-write" + } else { + anonAccess = "no-access" + } + if host == "" { + displayHost = "localhost" + } else { + displayHost = host + } + yamlConfig := fmt.Sprintf(defaultConfig, + displayHost, + port, + anonAccess, + len(pks) == 0, + ) + if len(pks) == 0 { + yamlUsers = defaultUserConfig + } else { + var result string + for _, pk := range pks { + result += fmt.Sprintf(" - %s\n", pk) + } + yamlUsers = fmt.Sprintf(hasKeyUserConfig, result) + } + yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig) + err := c.createDefaultConfigRepo(yaml) + if err != nil { + return nil, err + } + return c, nil } -// WithCallbacks applies the given Callbacks to the configuration. -func (c *Config) WithCallbacks(callbacks Callbacks) *Config { - c.Callbacks = callbacks - return c +// Reload reloads the configuration. +func (cfg *Config) Reload() error { + cfg.mtx.Lock() + defer cfg.mtx.Unlock() + err := cfg.Source.LoadRepos() + if err != nil { + return err + } + cr, err := cfg.Source.GetRepo("config") + if err != nil { + return err + } + cs, _, err := cr.LatestFile("config.yaml") + if err != nil { + return err + } + err = yaml.Unmarshal([]byte(cs), cfg) + if err != nil { + return fmt.Errorf("bad yaml in config.yaml: %s", err) + } + for _, r := range cfg.Source.AllRepos() { + name := r.Name() + err = r.UpdateServerInfo() + if err != nil { + log.Printf("error updating server info for %s: %s", name, err) + } + pat := "README*" + rp := "" + for _, rr := range cfg.Repos { + if name == rr.Repo { + rp = rr.Readme + r.name = rr.Name + r.description = rr.Note + r.private = rr.Private + break + } + } + if rp != "" { + pat = rp + } + rm := "" + fc, fp, _ := r.LatestFile(pat) + rm = fc + if name == "config" { + md, err := templatize(rm, cfg) + if err != nil { + return err + } + rm = md + } + r.SetReadme(rm, fp) + } + return nil +} + +func createFile(path string, content string) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(content) + if err != nil { + return err + } + return f.Sync() +} + +func (cfg *Config) createDefaultConfigRepo(yaml string) error { + cn := "config" + rp := filepath.Join(cfg.Cfg.RepoPath, cn) + rs := cfg.Source + err := rs.LoadRepo(cn) + if errors.Is(err, fs.ErrNotExist) { + repo, err := ggit.PlainInit(rp, true) + if err != nil { + return err + } + repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{ + URL: rp, + }) + if err != nil && err != transport.ErrEmptyRemoteRepository { + return err + } + wt, err := repo.Worktree() + if err != nil { + return err + } + rm, err := wt.Filesystem.Create("README.md") + if err != nil { + return err + } + _, err = rm.Write([]byte(defaultReadme)) + if err != nil { + return err + } + _, err = wt.Add("README.md") + if err != nil { + return err + } + cf, err := wt.Filesystem.Create("config.yaml") + if err != nil { + return err + } + _, err = cf.Write([]byte(yaml)) + if err != nil { + return err + } + _, err = wt.Add("config.yaml") + if err != nil { + return err + } + author := object.Signature{ + Name: "Soft Serve Server", + Email: "vt100@charm.sh", + When: time.Now(), + } + _, err = wt.Commit("Default init", &ggit.CommitOptions{ + All: true, + Author: &author, + Committer: &author, + }) + if err != nil { + return err + } + err = repo.Push(&ggit.PushOptions{}) + if err != nil { + return err + } + } else if err != nil { + return err + } + return cfg.Reload() } -// WithErrorLogger sets the error logger for the configuration. -func (c *Config) WithErrorLogger(logger *log.Logger) *Config { - c.ErrorLog = logger - return c +func (cfg *Config) isPrivate(repo string) bool { + for _, r := range cfg.Repos { + if r.Repo == repo { + return r.Private + } + } + return false +} + +func templatize(mdt string, tmpl interface{}) (string, error) { + t, err := template.New("readme").Parse(mdt) + if err != nil { + return "", err + } + buf := &bytes.Buffer{} + err = t.Execute(buf, tmpl) + if err != nil { + return "", err + } + return buf.String(), nil } diff --git a/config/config_test.go b/config/config_test.go index 6a8eb7e48..12ddd8c24 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/git/git.go b/config/git.go similarity index 83% rename from internal/git/git.go rename to config/git.go index 89762d524..857a5898e 100644 --- a/internal/git/git.go +++ b/config/git.go @@ -1,4 +1,4 @@ -package git +package config import ( "errors" @@ -17,13 +17,17 @@ var ErrMissingRepo = errors.New("missing repo") // Repo represents a Git repository. type Repo struct { - path string - repository *git.Repository - readme string - readmePath string - head *git.Reference - refs []*git.Reference - patchCache *lru.Cache + name string + description string + path string + repository *git.Repository + readme string + readmePath string + head *git.Reference + headCommit string + refs []*git.Reference + patchCache *lru.Cache + private bool } // open opens a Git repository. @@ -48,16 +52,34 @@ func (rs *RepoSource) open(path string) (*Repo, error) { return r, nil } +// IsPrivate returns true if the repository is private. +func (r *Repo) IsPrivate() bool { + return r.private +} + // Path returns the path to the repository. func (r *Repo) Path() string { return r.path } -// GetName returns the name of the repository. -func (r *Repo) Name() string { +// Repo returns the repository directory name. +func (r *Repo) Repo() string { return filepath.Base(r.path) } +// Name returns the name of the repository. +func (r *Repo) Name() string { + if r.name == "" { + return r.Repo() + } + return r.name +} + +// Description returns the description for a repository. +func (r *Repo) Description() string { + return r.description +} + // Readme returns the readme and its path for the repository. func (r *Repo) Readme() (readme string, path string) { return r.readme, r.readmePath @@ -124,6 +146,22 @@ func (r *Repo) CountCommits(ref *git.Reference) (int64, error) { return tc, nil } +// Commit returns the commit for a given hash. +func (r *Repo) Commit(hash string) (*git.Commit, error) { + if hash == "HEAD" && r.headCommit != "" { + hash = r.headCommit + } + c, err := r.repository.CatFileCommit(hash) + if err != nil { + return nil, err + } + r.headCommit = c.ID.String() + return &git.Commit{ + Commit: c, + Hash: git.Hash(c.ID.String()), + }, nil +} + // CommitsByPage returns the commits for a repository. func (r *Repo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) { return r.repository.CommitsByPage(ref, page, size) @@ -201,6 +239,7 @@ func (rs *RepoSource) LoadRepo(name string) error { rp := filepath.Join(rs.Path, name) r, err := rs.open(rp) if err != nil { + log.Printf("error opening repository %s: %s", name, err) return err } rs.repos[name] = r diff --git a/internal/config/testdata/k1.pub b/config/testdata/k1.pub similarity index 100% rename from internal/config/testdata/k1.pub rename to config/testdata/k1.pub diff --git a/examples/setuid/main.go b/examples/setuid/main.go index 4c05e5fb2..0c58fbb83 100644 --- a/examples/setuid/main.go +++ b/examples/setuid/main.go @@ -18,8 +18,8 @@ import ( "syscall" "time" - "github.com/charmbracelet/soft-serve/config" "github.com/charmbracelet/soft-serve/server" + "github.com/charmbracelet/soft-serve/server/config" ) var ( diff --git a/go.mod b/go.mod index a97f1892a..e683872aa 100755 --- a/go.mod +++ b/go.mod @@ -5,10 +5,10 @@ go 1.17 require ( github.com/alecthomas/chroma v0.10.0 github.com/caarlos0/env/v6 v6.9.1 - github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b - github.com/charmbracelet/bubbletea v0.20.0 - github.com/charmbracelet/glamour v0.4.0 - github.com/charmbracelet/lipgloss v0.4.0 + github.com/charmbracelet/bubbles v0.11.0 + github.com/charmbracelet/bubbletea v0.21.0 + github.com/charmbracelet/glamour v0.5.0 + github.com/charmbracelet/lipgloss v0.5.0 github.com/charmbracelet/wish v0.5.0 github.com/dustin/go-humanize v1.0.0 github.com/gliderlabs/ssh v0.3.4 @@ -16,12 +16,13 @@ require ( github.com/go-git/go-git/v5 v5.4.3-0.20210630082519-b4368b2a2ca4 github.com/matryer/is v1.4.0 github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 + github.com/muesli/termenv v0.12.0 github.com/sergi/go-diff v1.1.0 golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70 ) require ( + github.com/aymanbagabas/go-osc52 v1.0.3 github.com/charmbracelet/keygen v0.3.0 github.com/gobwas/glob v0.2.3 github.com/gogs/git-module v1.6.1 @@ -56,6 +57,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.17 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect + github.com/muesli/cancelreader v0.2.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/sahilm/fuzzy v0.1.0 // indirect @@ -64,7 +66,7 @@ require ( github.com/yuin/goldmark v1.4.4 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect - golang.org/x/sys v0.0.0-20220111092808-5a964db01320 // indirect - golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect + golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect + golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 629dced66..72c26ab8c 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= +github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k= @@ -24,21 +26,21 @@ github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Z github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I= github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= -github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b h1:o+LFpRn1fXtu1hDJLtBFjp7tMZ8AqwSpl84w1TnUj0Y= -github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA= -github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA= -github.com/charmbracelet/bubbletea v0.20.0 h1:/b8LEPgCbNr7WWZ2LuE/BV1/r4t5PyYJtDb+J3vpwxc= +github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= +github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= -github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k= -github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= -github.com/charmbracelet/harmonica v0.1.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= +github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= +github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= +github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g= +github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y= github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM= -github.com/charmbracelet/lipgloss v0.4.0 h1:768h64EFkGUr8V5yAKV7/Ta0NiVceiPaV+PphaW1K9g= github.com/charmbracelet/lipgloss v0.4.0/go.mod h1:vmdkHvce7UzX6xkyf4cca8WlwdQ5RQr8fzta+xl7BOM= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg= github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk= -github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -120,6 +122,8 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA= github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= @@ -128,8 +132,10 @@ github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKt github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= -github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= +github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= @@ -200,12 +206,14 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220111092808-5a964db01320 h1:0jf+tOCoZ3LyutmCOWpVni1chK4VfFLhRsDK7MhqGRY= -golang.org/x/sys v0.0.0-20220111092808-5a964db01320/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 h1:EH1Deb8WZJ0xc0WK//leUHXcX9aLE5SymusoTmMZye8= +golang.org/x/term v0.0.0-20220411215600-e5f449aeb171/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 86cbc52b7..000000000 --- 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 724cb5c83..000000000 --- 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/tui/bubble.go b/internal/tui/bubble.go deleted file mode 100644 index 436dbc4db..000000000 --- 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 6e9a34a99..000000000 --- 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/bubbles/selection/bubble.go b/internal/tui/bubbles/selection/bubble.go deleted file mode 100644 index 8be37dcf3..000000000 --- a/internal/tui/bubbles/selection/bubble.go +++ /dev/null @@ -1,107 +0,0 @@ -package selection - -import ( - "strings" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/charmbracelet/soft-serve/tui/common" - "github.com/muesli/reflow/truncate" -) - -type SelectedMsg struct { - Name string - Index int -} - -type ActiveMsg struct { - Name string - Index int -} - -type Bubble struct { - Items []string - SelectedItem int - styles *style.Styles -} - -func NewBubble(items []string, styles *style.Styles) *Bubble { - return &Bubble{ - Items: items, - styles: styles, - } -} - -func (b *Bubble) Init() tea.Cmd { - return nil -} - -func (b Bubble) View() string { - s := strings.Builder{} - repoNameMaxWidth := b.styles.Menu.GetWidth() - // menu width - b.styles.Menu.GetHorizontalPadding() - // menu padding - lipgloss.Width(b.styles.MenuCursor.String()) - // cursor - b.styles.MenuItem.GetHorizontalFrameSize() // menu item gaps - for i, item := range b.Items { - item := truncate.StringWithTail(item, uint(repoNameMaxWidth), "…") - if i == b.SelectedItem { - s.WriteString(b.styles.MenuCursor.String()) - s.WriteString(b.styles.SelectedMenuItem.Render(item)) - } else { - s.WriteString(b.styles.MenuItem.Render(item)) - } - if i < len(b.Items)-1 { - s.WriteRune('\n') - } - } - return s.String() -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.KeyMsg: - switch msg.String() { - case "k", "up": - if b.SelectedItem > 0 { - b.SelectedItem-- - cmds = append(cmds, b.sendActiveMessage) - } - case "j", "down": - if b.SelectedItem < len(b.Items)-1 { - b.SelectedItem++ - cmds = append(cmds, b.sendActiveMessage) - } - case "enter": - cmds = append(cmds, b.sendSelectedMessage) - } - } - return b, tea.Batch(cmds...) -} - -func (b *Bubble) Help() []common.HelpEntry { - return []common.HelpEntry{ - {Key: "↑/↓", Value: "navigate"}, - } -} - -func (b *Bubble) sendActiveMessage() tea.Msg { - if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) { - return ActiveMsg{ - Name: b.Items[b.SelectedItem], - Index: b.SelectedItem, - } - } - return nil -} - -func (b *Bubble) sendSelectedMessage() tea.Msg { - if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) { - return SelectedMsg{ - Name: b.Items[b.SelectedItem], - Index: b.SelectedItem, - } - } - return nil -} diff --git a/internal/tui/commands.go b/internal/tui/commands.go deleted file mode 100644 index 5be3c9d17..000000000 --- a/internal/tui/commands.go +++ /dev/null @@ -1,121 +0,0 @@ -package tui - -import ( - "fmt" - - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo" - "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection" - "github.com/charmbracelet/soft-serve/tui/common" - gm "github.com/charmbracelet/wish/git" -) - -type errMsg struct{ err error } - -func (e errMsg) Error() string { - return e.err.Error() -} - -func (b *Bubble) setupCmd() tea.Msg { - if b.config == nil || b.config.Source == nil { - return errMsg{err: fmt.Errorf("config not set")} - } - mes, err := b.menuEntriesFromSource() - if err != nil { - return errMsg{err} - } - if len(mes) == 0 { - return errMsg{fmt.Errorf("no repos found")} - } - b.repoMenu = mes - rs := make([]string, 0) - for _, m := range mes { - rs = append(rs, m.Name) - } - b.repoSelect = selection.NewBubble(rs, b.styles) - b.boxes[0] = b.repoSelect - - // Jump to an initial repo - ir := -1 - if b.initialRepo != "" { - for i, me := range b.repoMenu { - if me.Repo == b.initialRepo { - ir = i - } - } - } - if ir == -1 { - b.boxes[1] = b.repoMenu[0].bubble - b.activeBox = 0 - } else { - b.boxes[1] = b.repoMenu[ir].bubble - b.repoSelect.SelectedItem = ir - b.activeBox = 1 - } - - b.state = loadedState - return nil -} - -func (b *Bubble) menuEntriesFromSource() ([]MenuEntry, error) { - mes := make([]MenuEntry, 0) - for _, cr := range b.config.Repos { - acc := b.config.AuthRepo(cr.Repo, b.session.PublicKey()) - if acc == gm.NoAccess && cr.Repo != "config" { - continue - } - if cr.Private && acc < gm.ReadOnlyAccess { - continue - } - me, err := b.newMenuEntry(cr.Name, cr.Repo) - if err != nil { - return nil, err - } - mes = append(mes, me) - } - for _, r := range b.config.Source.AllRepos() { - var found bool - rn := r.Name() - for _, me := range mes { - if me.Repo == rn { - found = true - } - } - if !found { - acc := b.config.AuthRepo(rn, b.session.PublicKey()) - if acc == gm.NoAccess { - continue - } - me, err := b.newMenuEntry(rn, rn) - if err != nil { - return nil, err - } - mes = append(mes, me) - } - } - return mes, nil -} - -func (b *Bubble) newMenuEntry(name string, rn string) (MenuEntry, error) { - me := MenuEntry{Name: name, Repo: rn} - r, err := b.config.Source.GetRepo(rn) - if err != nil { - return me, err - } - boxLeftWidth := b.styles.Menu.GetWidth() + b.styles.Menu.GetHorizontalFrameSize() - // TODO: also send this along with a tea.WindowSizeMsg - var heightMargin = lipgloss.Height(b.headerView()) + - lipgloss.Height(b.footerView()) + - b.styles.RepoBody.GetVerticalFrameSize() + - b.styles.App.GetVerticalMargins() - rb := repo.NewBubble(r, b.config.Host, b.config.Port, b.styles, b.width, boxLeftWidth, b.height, heightMargin) - initCmd := rb.Init() - msg := initCmd() - switch msg := msg.(type) { - case common.ErrMsg: - return me, fmt.Errorf("missing %s: %s", me.Repo, msg.Err.Error()) - } - me.bubble = rb - return me, nil -} diff --git a/internal/tui/session.go b/internal/tui/session.go deleted file mode 100644 index 7501769e1..000000000 --- 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/cat.go b/server/cmd/cat.go index 18ff5678c..750089186 100644 --- a/server/cmd/cat.go +++ b/server/cmd/cat.go @@ -7,8 +7,8 @@ import ( "github.com/alecthomas/chroma/lexers" gansi "github.com/charmbracelet/glamour/ansi" "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/git" - "github.com/charmbracelet/soft-serve/tui/common" + "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/ui/common" gitwish "github.com/charmbracelet/wish/git" "github.com/muesli/termenv" "github.com/spf13/cobra" @@ -40,10 +40,10 @@ func CatCommand() *cobra.Command { if auth < gitwish.ReadOnlyAccess { return ErrUnauthorized } - var repo *git.Repo + var repo *config.Repo repoExists := false for _, rp := range ac.Source.AllRepos() { - if rp.Name() == rn { + if rp.Repo() == rn { repoExists = true repo = rp break @@ -109,7 +109,7 @@ func withFormatting(p, c string) (string, error) { Language: lang, } r := strings.Builder{} - styles := common.DefaultStyles() + styles := common.StyleConfig() styles.CodeBlock.Margin = &zero rctx := gansi.NewRenderContext(gansi.Options{ Styles: styles, diff --git a/server/cmd/cmd.go b/server/cmd/cmd.go index 157a1c2bb..684fe7ec6 100644 --- a/server/cmd/cmd.go +++ b/server/cmd/cmd.go @@ -3,11 +3,26 @@ package cmd import ( "fmt" - appCfg "github.com/charmbracelet/soft-serve/internal/config" + appCfg "github.com/charmbracelet/soft-serve/config" "github.com/gliderlabs/ssh" "github.com/spf13/cobra" ) +// ContextKey is a type that can be used as a key in a context. +type ContextKey string + +// String returns the string representation of the ContextKey. +func (c ContextKey) String() string { + return "soft-serve cli context key " + string(c) +} + +var ( + // ConfigCtxKey is the key for the config in the context. + ConfigCtxKey = ContextKey("config") + // SessionCtxKey is the key for the session in the context. + SessionCtxKey = ContextKey("session") +) + var ( // ErrUnauthorized is returned when the user is not authorized to perform action. ErrUnauthorized = fmt.Errorf("Unauthorized") @@ -64,7 +79,7 @@ func RootCommand() *cobra.Command { func fromContext(cmd *cobra.Command) (*appCfg.Config, ssh.Session) { ctx := cmd.Context() - ac := ctx.Value("config").(*appCfg.Config) - s := ctx.Value("session").(ssh.Session) + ac := ctx.Value(ConfigCtxKey).(*appCfg.Config) + s := ctx.Value(SessionCtxKey).(ssh.Session) return ac, s } diff --git a/server/cmd/git.go b/server/cmd/git.go index fd2c3be87..d9a60070e 100644 --- a/server/cmd/git.go +++ b/server/cmd/git.go @@ -4,7 +4,7 @@ import ( "io" "os/exec" - "github.com/charmbracelet/soft-serve/internal/git" + "github.com/charmbracelet/soft-serve/config" gitwish "github.com/charmbracelet/wish/git" "github.com/spf13/cobra" ) @@ -23,11 +23,11 @@ func GitCommand() *cobra.Command { if len(args) < 1 { return runGit(nil, s, s, "") } - var repo *git.Repo + var repo *config.Repo rn := args[0] repoExists := false for _, rp := range ac.Source.AllRepos() { - if rp.Name() == rn { + if rp.Repo() == rn { repoExists = true repo = rp break diff --git a/server/cmd/list.go b/server/cmd/list.go index f07a2a68f..e1b239b18 100644 --- a/server/cmd/list.go +++ b/server/cmd/list.go @@ -33,7 +33,7 @@ func ListCommand() *cobra.Command { } if path == "" || path == "." || path == "/" { for _, r := range ac.Source.AllRepos() { - fmt.Fprintln(s, r.Name()) + fmt.Fprintln(s, r.Repo()) } return nil } diff --git a/server/config/config.go b/server/config/config.go new file mode 100644 index 000000000..dc60e2dad --- /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 000000000..6a8eb7e48 --- /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 7a6bc3674..e3e62d154 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" @@ -19,8 +19,8 @@ func softMiddleware(ac *appCfg.Config) wish.Middleware { if active { return } - ctx := context.WithValue(s.Context(), "config", ac) //nolint:revive - ctx = context.WithValue(ctx, "session", s) //nolint:revive + ctx := context.WithValue(s.Context(), cmd.ConfigCtxKey, ac) + ctx = context.WithValue(ctx, cmd.SessionCtxKey, s) use := "ssh" port := ac.Port diff --git a/server/middleware_test.go b/server/middleware_test.go index 94ac3ebbf..6895d3d10 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 708aa3e2f..cbf40fd66 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. @@ -37,10 +37,10 @@ func NewServer(cfg *config.Config) *Server { mw := []wish.Middleware{ rm.MiddlewareWithLogger( cfg.ErrorLog, - lm.Middleware(), softMiddleware(ac), - bm.Middleware(tui.SessionHandler(ac)), + bm.MiddlewareWithProgramHandler(SessionHandler(ac), termenv.ANSI256), gm.Middleware(cfg.RepoPath, ac), + lm.Middleware(), ), } s, err := wish.NewServer( diff --git a/server/server_test.go b/server/server_test.go index 0756e7f53..d7dcf9b8b 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 000000000..de6f2d4c3 --- /dev/null +++ b/server/session.go @@ -0,0 +1,57 @@ +package server + +import ( + "fmt" + + "github.com/aymanbagabas/go-osc52" + tea "github.com/charmbracelet/bubbletea" + appCfg "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/ui" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/keymap" + "github.com/charmbracelet/soft-serve/ui/styles" + bm "github.com/charmbracelet/wish/bubbletea" + "github.com/gliderlabs/ssh" +) + +// SessionHandler is the soft-serve bubbletea ssh session handler. +func SessionHandler(ac *appCfg.Config) bm.ProgramHandler { + return func(s ssh.Session) *tea.Program { + pty, _, active := s.Pty() + if !active { + return nil + } + cmd := s.Command() + initialRepo := "" + if len(cmd) == 1 { + initialRepo = cmd[0] + } + if ac.Cfg.Callbacks != nil { + ac.Cfg.Callbacks.Tui("new session") + } + envs := s.Environ() + envs = append(envs, fmt.Sprintf("TERM=%s", pty.Term)) + output := osc52.NewOutput(s, envs) + c := common.Common{ + Copy: output, + Styles: styles.DefaultStyles(), + KeyMap: keymap.DefaultKeyMap(), + Width: pty.Window.Width, + Height: pty.Window.Height, + } + m := ui.New( + ac, + s, + c, + initialRepo, + ) + p := tea.NewProgram(m, + tea.WithInput(s), + tea.WithOutput(s), + tea.WithAltScreen(), + tea.WithoutCatchPanics(), + tea.WithMouseCellMotion(), + ) + return p + } +} diff --git a/tui/about/bubble.go b/tui/about/bubble.go deleted file mode 100644 index 9205268e9..000000000 --- a/tui/about/bubble.go +++ /dev/null @@ -1,122 +0,0 @@ -package about - -import ( - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/charmbracelet/soft-serve/tui/common" - "github.com/charmbracelet/soft-serve/tui/refs" - vp "github.com/charmbracelet/soft-serve/tui/viewport" - "github.com/muesli/reflow/wrap" -) - -type Bubble struct { - readmeViewport *vp.ViewportBubble - repo common.GitRepo - styles *style.Styles - height int - heightMargin int - width int - widthMargin int - ref *git.Reference -} - -func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble { - b := &Bubble{ - readmeViewport: &vp.ViewportBubble{ - Viewport: &viewport.Model{}, - }, - repo: repo, - styles: styles, - widthMargin: wm, - heightMargin: hm, - } - b.SetSize(width, height) - return b -} -func (b *Bubble) Init() tea.Cmd { - return b.reset -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case tea.WindowSizeMsg: - b.SetSize(msg.Width, msg.Height) - // XXX: if we find that longer readmes take more than a few - // milliseconds to render we may need to move Glamour rendering into a - // command. - md, err := b.glamourize() - if err != nil { - return b, nil - } - b.readmeViewport.Viewport.SetContent(md) - case tea.KeyMsg: - switch msg.String() { - case "R": - return b, b.reset - } - case refs.RefMsg: - b.ref = msg - return b, b.reset - } - rv, cmd := b.readmeViewport.Update(msg) - b.readmeViewport = rv.(*vp.ViewportBubble) - cmds = append(cmds, cmd) - return b, tea.Batch(cmds...) -} - -func (b *Bubble) SetSize(w, h int) { - b.width = w - b.height = h - b.readmeViewport.Viewport.Width = w - b.widthMargin - b.readmeViewport.Viewport.Height = h - b.heightMargin -} - -func (b *Bubble) GotoTop() { - b.readmeViewport.Viewport.GotoTop() -} - -func (b *Bubble) View() string { - return b.readmeViewport.View() -} - -func (b *Bubble) Help() []common.HelpEntry { - return nil -} - -func (b *Bubble) glamourize() (string, error) { - w := b.width - b.widthMargin - b.styles.RepoBody.GetHorizontalFrameSize() - rm, rp := b.repo.Readme() - if rm == "" { - return b.styles.AboutNoReadme.Render("No readme found."), nil - } - f, err := common.RenderFile(rp, rm, w) - if err != nil { - return "", err - } - // For now, hard-wrap long lines in Glamour that would otherwise break the - // layout when wrapping. This may be due to #43 in Reflow, which has to do - // with a bug in the way lines longer than the given width are wrapped. - // - // https://github.com/muesli/reflow/issues/43 - // - // TODO: solve this upstream in Glamour/Reflow. - return wrap.String(f, w), nil -} - -func (b *Bubble) reset() tea.Msg { - md, err := b.glamourize() - if err != nil { - return common.ErrMsg{Err: err} - } - head, err := b.repo.HEAD() - if err != nil { - return common.ErrMsg{Err: err} - } - b.ref = head - b.readmeViewport.Viewport.SetContent(md) - b.GotoTop() - return nil -} diff --git a/tui/bubble.go b/tui/bubble.go deleted file mode 100644 index e23442472..000000000 --- a/tui/bubble.go +++ /dev/null @@ -1,155 +0,0 @@ -package tui - -import ( - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/charmbracelet/soft-serve/tui/about" - "github.com/charmbracelet/soft-serve/tui/common" - "github.com/charmbracelet/soft-serve/tui/log" - "github.com/charmbracelet/soft-serve/tui/refs" - "github.com/charmbracelet/soft-serve/tui/tree" -) - -const ( - repoNameMaxWidth = 32 -) - -type state int - -const ( - aboutState state = iota - refsState - logState - treeState -) - -type Bubble struct { - state state - repo common.GitRepo - height int - heightMargin int - width int - widthMargin int - style *style.Styles - boxes []tea.Model - ref *git.Reference -} - -func NewBubble(repo common.GitRepo, styles *style.Styles, width, wm, height, hm int) *Bubble { - b := &Bubble{ - repo: repo, - state: aboutState, - width: width, - widthMargin: wm, - height: height, - heightMargin: hm, - style: styles, - boxes: make([]tea.Model, 4), - } - heightMargin := hm + lipgloss.Height(b.headerView()) - b.boxes[aboutState] = about.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin) - b.boxes[refsState] = refs.NewBubble(repo, b.style, b.width, wm, b.height, heightMargin) - b.boxes[logState] = log.NewBubble(repo, b.style, width, wm, height, heightMargin) - b.boxes[treeState] = tree.NewBubble(repo, b.style, width, wm, height, heightMargin) - return b -} - -func (b *Bubble) Init() tea.Cmd { - return b.setupCmd -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.KeyMsg: - if b.repo.Name() != "config" { - switch msg.String() { - case "R": - b.state = aboutState - case "B": - b.state = refsState - case "C": - b.state = logState - case "F": - b.state = treeState - } - } - case tea.WindowSizeMsg: - b.width = msg.Width - b.height = msg.Height - for i, bx := range b.boxes { - m, cmd := bx.Update(msg) - b.boxes[i] = m - if cmd != nil { - cmds = append(cmds, cmd) - } - } - case refs.RefMsg: - b.state = treeState - b.ref = msg - for i, bx := range b.boxes { - m, cmd := bx.Update(msg) - b.boxes[i] = m - if cmd != nil { - cmds = append(cmds, cmd) - } - } - } - m, cmd := b.boxes[b.state].Update(msg) - b.boxes[b.state] = m - if cmd != nil { - cmds = append(cmds, cmd) - } - return b, tea.Batch(cmds...) -} - -func (b *Bubble) Help() []common.HelpEntry { - h := []common.HelpEntry{} - h = append(h, b.boxes[b.state].(common.BubbleHelper).Help()...) - if b.repo.Name() != "config" { - h = append(h, common.HelpEntry{Key: "R", Value: "readme"}) - h = append(h, common.HelpEntry{Key: "F", Value: "files"}) - h = append(h, common.HelpEntry{Key: "C", Value: "commits"}) - h = append(h, common.HelpEntry{Key: "B", Value: "branches"}) - } - return h -} - -func (b *Bubble) Reference() *git.Reference { - return b.ref -} - -func (b *Bubble) headerView() string { - // TODO better header, tabs? - return "" -} - -func (b *Bubble) View() string { - header := b.headerView() - return header + b.boxes[b.state].View() -} - -func (b *Bubble) setupCmd() tea.Msg { - head, err := b.repo.HEAD() - if err != nil { - return common.ErrMsg{Err: err} - } - b.ref = head - cmds := make([]tea.Cmd, 0) - for _, bx := range b.boxes { - if bx != nil { - initCmd := bx.Init() - if initCmd != nil { - msg := initCmd() - switch msg := msg.(type) { - case common.ErrMsg: - return msg - } - } - cmds = append(cmds, initCmd) - } - } - return tea.Batch(cmds...) -} diff --git a/tui/common/consts.go b/tui/common/consts.go deleted file mode 100644 index c915df177..000000000 --- a/tui/common/consts.go +++ /dev/null @@ -1,28 +0,0 @@ -package common - -import ( - "time" - - "github.com/charmbracelet/bubbles/key" -) - -// Some constants were copied from https://docs.gitea.io/en-us/config-cheat-sheet/#git-git - -const ( - GlamourMaxWidth = 120 - RepoNameMaxWidth = 32 - MaxDiffLines = 1000 - MaxDiffFiles = 100 - MaxPatchWait = time.Second * 3 -) - -var ( - PrevPage = key.NewBinding( - key.WithKeys("pgup", "b", "u"), - key.WithHelp("pgup", "prev page"), - ) - NextPage = key.NewBinding( - key.WithKeys("pgdown", "f", "d"), - key.WithHelp("pgdn", "next page"), - ) -) diff --git a/tui/common/error.go b/tui/common/error.go deleted file mode 100644 index b9ecc9594..000000000 --- a/tui/common/error.go +++ /dev/null @@ -1,36 +0,0 @@ -package common - -import ( - "errors" - - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/internal/tui/style" -) - -var ( - ErrDiffTooLong = errors.New("diff is too long") - ErrDiffFilesTooLong = errors.New("diff files are too long") - ErrBinaryFile = errors.New("binary file") - ErrFileTooLarge = errors.New("file is too large") - ErrInvalidFile = errors.New("invalid file") -) - -type ErrMsg struct { - Err error -} - -func (e ErrMsg) Error() string { - return e.Err.Error() -} - -func (e ErrMsg) View(s *style.Styles) string { - return e.ViewWithPrefix(s, "") -} - -func (e ErrMsg) ViewWithPrefix(s *style.Styles, prefix string) string { - return lipgloss.JoinHorizontal( - lipgloss.Top, - s.ErrorTitle.Render(prefix), - s.ErrorBody.Render(e.Error()), - ) -} diff --git a/tui/common/formatter.go b/tui/common/formatter.go deleted file mode 100644 index 11143ec48..000000000 --- a/tui/common/formatter.go +++ /dev/null @@ -1,88 +0,0 @@ -package common - -import ( - "strings" - - "github.com/alecthomas/chroma/lexers" - "github.com/charmbracelet/glamour" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/muesli/termenv" -) - -var ( - RenderCtx = DefaultRenderCtx() - Styles = DefaultStyles() -) - -func DefaultStyles() gansi.StyleConfig { - noColor := "" - s := glamour.DarkStyleConfig - s.Document.StylePrimitive.Color = &noColor - s.CodeBlock.Chroma.Text.Color = &noColor - s.CodeBlock.Chroma.Name.Color = &noColor - return s -} - -func DefaultRenderCtx() gansi.RenderContext { - return gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: DefaultStyles(), - }) -} - -func NewRenderCtx(worldwrap int) gansi.RenderContext { - return gansi.NewRenderContext(gansi.Options{ - ColorProfile: termenv.TrueColor, - Styles: DefaultStyles(), - WordWrap: worldwrap, - }) -} - -func Glamourize(w int, md string) (string, error) { - if w > GlamourMaxWidth { - w = GlamourMaxWidth - } - tr, err := glamour.NewTermRenderer( - glamour.WithStyles(DefaultStyles()), - glamour.WithWordWrap(w), - ) - - if err != nil { - return "", err - } - mdt, err := tr.Render(md) - if err != nil { - return "", err - } - return mdt, nil -} - -func RenderFile(path, content string, width int) (string, error) { - lexer := lexers.Fallback - if path == "" { - lexer = lexers.Analyse(content) - } else { - lexer = lexers.Match(path) - } - lang := "" - if lexer != nil && lexer.Config() != nil { - lang = lexer.Config().Name - } - formatter := &gansi.CodeBlockElement{ - Code: content, - Language: lang, - } - if lang == "markdown" { - md, err := Glamourize(width, content) - if err != nil { - return "", err - } - return md, nil - } - r := strings.Builder{} - err := formatter.Render(&r, RenderCtx) - if err != nil { - return "", err - } - return r.String(), nil -} diff --git a/tui/common/git.go b/tui/common/git.go deleted file mode 100644 index 43cb53ca4..000000000 --- a/tui/common/git.go +++ /dev/null @@ -1,16 +0,0 @@ -package common - -import ( - "github.com/charmbracelet/soft-serve/git" -) - -type GitRepo interface { - Name() string - Readme() (string, string) - HEAD() (*git.Reference, error) - CommitsByPage(*git.Reference, int, int) (git.Commits, error) - CountCommits(*git.Reference) (int64, error) - Diff(*git.Commit) (*git.Diff, error) - References() ([]*git.Reference, error) - Tree(*git.Reference, string) (*git.Tree, error) -} diff --git a/tui/common/help.go b/tui/common/help.go deleted file mode 100644 index 5bc1a9a8d..000000000 --- a/tui/common/help.go +++ /dev/null @@ -1,10 +0,0 @@ -package common - -type BubbleHelper interface { - Help() []HelpEntry -} - -type HelpEntry struct { - Key string - Value string -} diff --git a/tui/common/reset.go b/tui/common/reset.go deleted file mode 100644 index fe92b4715..000000000 --- a/tui/common/reset.go +++ /dev/null @@ -1,7 +0,0 @@ -package common - -import tea "github.com/charmbracelet/bubbletea" - -type BubbleReset interface { - Reset() tea.Msg -} diff --git a/tui/common/utils.go b/tui/common/utils.go deleted file mode 100644 index 2cafee64e..000000000 --- a/tui/common/utils.go +++ /dev/null @@ -1,17 +0,0 @@ -package common - -import "github.com/muesli/reflow/truncate" - -func TruncateString(s string, max int, tail string) string { - if max < 0 { - max = 0 - } - return truncate.StringWithTail(s, uint(max), tail) -} - -func Max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/tui/log/bubble.go b/tui/log/bubble.go deleted file mode 100644 index 30c35069b..000000000 --- a/tui/log/bubble.go +++ /dev/null @@ -1,383 +0,0 @@ -package log - -import ( - "fmt" - "io" - "strings" - "time" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/spinner" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - gansi "github.com/charmbracelet/glamour/ansi" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/charmbracelet/soft-serve/tui/common" - "github.com/charmbracelet/soft-serve/tui/refs" - vp "github.com/charmbracelet/soft-serve/tui/viewport" -) - -var ( - diffChroma = &gansi.CodeBlockElement{ - Code: "", - Language: "diff", - } - waitBeforeLoading = time.Millisecond * 300 -) - -type itemsMsg struct{} - -type commitMsg *git.Commit - -type countMsg int64 - -type sessionState int - -const ( - logState sessionState = iota - commitState - errorState -) - -type item struct { - *git.Commit -} - -func (i item) Title() string { - if i.Commit != nil { - return strings.Split(i.Commit.Message, "\n")[0] - } - return "" -} - -func (i item) FilterValue() string { return i.Title() } - -type itemDelegate struct { - style *style.Styles -} - -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - i, ok := listItem.(item) - if !ok { - return - } - if i.Commit == nil { - return - } - - hash := i.ID.String() - leftMargin := d.style.LogItemSelector.GetMarginLeft() + - d.style.LogItemSelector.GetWidth() + - d.style.LogItemHash.GetMarginLeft() + - d.style.LogItemHash.GetWidth() + - d.style.LogItemInactive.GetMarginLeft() - title := common.TruncateString(i.Title(), m.Width()-leftMargin, "…") - if index == m.Index() { - fmt.Fprint(w, d.style.LogItemSelector.Render(">")+ - d.style.LogItemHash.Bold(true).Render(hash[:7])+ - d.style.LogItemActive.Render(title)) - } else { - fmt.Fprint(w, d.style.LogItemSelector.Render(" ")+ - d.style.LogItemHash.Render(hash[:7])+ - d.style.LogItemInactive.Render(title)) - } -} - -type Bubble struct { - repo common.GitRepo - count int64 - list list.Model - state sessionState - commitViewport *vp.ViewportBubble - ref *git.Reference - style *style.Styles - width int - widthMargin int - height int - heightMargin int - error common.ErrMsg - spinner spinner.Model - loading bool - loadingStart time.Time - selectedCommit *git.Commit - nextPage int -} - -func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { - l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin) - l.SetShowFilter(false) - l.SetShowHelp(false) - l.SetShowPagination(true) - l.SetShowStatusBar(false) - l.SetShowTitle(false) - l.SetFilteringEnabled(false) - l.DisableQuitKeybindings() - l.KeyMap.NextPage = common.NextPage - l.KeyMap.PrevPage = common.PrevPage - s := spinner.New() - s.Spinner = spinner.Dot - s.Style = styles.Spinner - b := &Bubble{ - commitViewport: &vp.ViewportBubble{ - Viewport: &viewport.Model{}, - }, - repo: repo, - style: styles, - state: logState, - width: width, - widthMargin: widthMargin, - height: height, - heightMargin: heightMargin, - list: l, - spinner: s, - } - b.SetSize(width, height) - return b -} - -func (b *Bubble) countCommits() tea.Msg { - if b.ref == nil { - ref, err := b.repo.HEAD() - if err != nil { - return common.ErrMsg{Err: err} - } - b.ref = ref - } - count, err := b.repo.CountCommits(b.ref) - if err != nil { - return common.ErrMsg{Err: err} - } - return countMsg(count) -} - -func (b *Bubble) updateItems() tea.Msg { - if b.count == 0 { - b.count = int64(b.countCommits().(countMsg)) - } - count := b.count - items := make([]list.Item, count) - page := b.nextPage - limit := b.list.Paginator.PerPage - skip := page * limit - // CommitsByPage pages start at 1 - cc, err := b.repo.CommitsByPage(b.ref, page+1, limit) - if err != nil { - return common.ErrMsg{Err: err} - } - for i, c := range cc { - idx := i + skip - if int64(idx) >= count { - break - } - items[idx] = item{c} - } - b.list.SetItems(items) - b.SetSize(b.width, b.height) - return itemsMsg{} -} - -func (b *Bubble) Help() []common.HelpEntry { - return nil -} - -func (b *Bubble) GotoTop() { - b.commitViewport.Viewport.GotoTop() -} - -func (b *Bubble) Init() tea.Cmd { - return nil -} - -func (b *Bubble) SetSize(width, height int) { - b.width = width - b.height = height - b.commitViewport.Viewport.Width = width - b.widthMargin - b.commitViewport.Viewport.Height = height - b.heightMargin - b.list.SetSize(width-b.widthMargin, height-b.heightMargin) - b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin) -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - b.SetSize(msg.Width, msg.Height) - cmds = append(cmds, b.updateItems) - - case tea.KeyMsg: - switch msg.String() { - case "C": - b.count = 0 - b.loading = true - b.loadingStart = time.Now().Add(-waitBeforeLoading) // always show spinner - b.list.Select(0) - b.nextPage = 0 - return b, tea.Batch(b.updateItems, b.spinner.Tick) - case "enter", "right", "l": - if b.state == logState { - i := b.list.SelectedItem() - if i != nil { - c, ok := i.(item) - if ok { - b.selectedCommit = c.Commit - } - } - cmds = append(cmds, b.loadCommit, b.spinner.Tick) - } - case "esc", "left", "h": - if b.state != logState { - b.state = logState - b.selectedCommit = nil - } - } - switch b.state { - case logState: - curPage := b.list.Paginator.Page - m, cmd := b.list.Update(msg) - b.list = m - if m.Paginator.Page != curPage { - b.loading = true - b.loadingStart = time.Now() - b.list.Paginator.Page = curPage - b.nextPage = m.Paginator.Page - cmds = append(cmds, b.updateItems, b.spinner.Tick) - } - cmds = append(cmds, cmd) - case commitState: - rv, cmd := b.commitViewport.Update(msg) - b.commitViewport = rv.(*vp.ViewportBubble) - cmds = append(cmds, cmd) - } - return b, tea.Batch(cmds...) - case itemsMsg: - b.loading = false - b.list.Paginator.Page = b.nextPage - if b.state != commitState { - b.state = logState - } - case countMsg: - b.count = int64(msg) - case common.ErrMsg: - b.error = msg - b.state = errorState - b.loading = false - return b, nil - case commitMsg: - b.loading = false - b.state = commitState - case refs.RefMsg: - b.ref = msg - b.count = 0 - cmds = append(cmds, b.countCommits) - case spinner.TickMsg: - if b.loading { - s, cmd := b.spinner.Update(msg) - if cmd != nil { - cmds = append(cmds, cmd) - } - b.spinner = s - } - } - - return b, tea.Batch(cmds...) -} - -func (b *Bubble) loadPatch(c *git.Commit) error { - var patch strings.Builder - style := b.style.LogCommit.Copy().Width(b.width - b.widthMargin - b.style.LogCommit.GetHorizontalFrameSize()) - p, err := b.repo.Diff(c) - if err != nil { - return err - } - stats := strings.Split(p.Stats().String(), "\n") - for i, l := range stats { - ch := strings.Split(l, "|") - if len(ch) > 1 { - adddel := ch[len(ch)-1] - adddel = strings.ReplaceAll(adddel, "+", b.style.LogCommitStatsAdd.Render("+")) - adddel = strings.ReplaceAll(adddel, "-", b.style.LogCommitStatsDel.Render("-")) - stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel - } - } - patch.WriteString(b.renderCommit(c)) - fpl := len(p.Files) - if fpl > common.MaxDiffFiles { - patch.WriteString("\n" + common.ErrDiffFilesTooLong.Error()) - } else { - patch.WriteString("\n" + strings.Join(stats, "\n")) - } - if fpl <= common.MaxDiffFiles { - ps := "" - if len(strings.Split(ps, "\n")) > common.MaxDiffLines { - patch.WriteString("\n" + common.ErrDiffTooLong.Error()) - } else { - patch.WriteString("\n" + b.renderDiff(p)) - } - } - content := style.Render(patch.String()) - b.commitViewport.Viewport.SetContent(content) - b.GotoTop() - return nil -} - -func (b *Bubble) loadCommit() tea.Msg { - b.loading = true - b.loadingStart = time.Now() - c := b.selectedCommit - if err := b.loadPatch(c); err != nil { - return common.ErrMsg{Err: err} - } - return commitMsg(c) -} - -func (b *Bubble) renderCommit(c *git.Commit) string { - s := strings.Builder{} - // FIXME: lipgloss prints empty lines when CRLF is used - // sanitize commit message from CRLF - msg := strings.ReplaceAll(c.Message, "\r\n", "\n") - s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", - b.style.LogCommitHash.Render("commit "+c.ID.String()), - b.style.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)), - b.style.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), - b.style.LogCommitBody.Render(msg), - )) - return s.String() -} - -func (b *Bubble) renderDiff(diff *git.Diff) string { - var s strings.Builder - var pr strings.Builder - diffChroma.Code = diff.Patch() - err := diffChroma.Render(&pr, common.RenderCtx) - if err != nil { - s.WriteString(fmt.Sprintf("\n%s", err.Error())) - } else { - s.WriteString(fmt.Sprintf("\n%s", pr.String())) - } - return s.String() -} - -func (b *Bubble) View() string { - if b.loading && b.loadingStart.Add(waitBeforeLoading).Before(time.Now()) { - msg := fmt.Sprintf("%s loading commit", b.spinner.View()) - if b.selectedCommit == nil { - msg += "s" - } - msg += "…" - return msg - } - switch b.state { - case logState: - return b.list.View() - case errorState: - return b.error.ViewWithPrefix(b.style, "Error") - case commitState: - return b.commitViewport.View() - default: - return "" - } -} diff --git a/tui/refs/bubble.go b/tui/refs/bubble.go deleted file mode 100644 index 7a794c2b6..000000000 --- a/tui/refs/bubble.go +++ /dev/null @@ -1,185 +0,0 @@ -package refs - -import ( - "fmt" - "io" - "sort" - - "github.com/charmbracelet/bubbles/list" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/charmbracelet/soft-serve/tui/common" -) - -type RefMsg = *git.Reference - -type item struct { - *git.Reference -} - -func (i item) Short() string { - return i.Reference.Name().Short() -} - -func (i item) FilterValue() string { return i.Short() } - -type items []item - -func (cl items) Len() int { return len(cl) } -func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } -func (cl items) Less(i, j int) bool { - return cl[i].Short() < cl[j].Short() -} - -type itemDelegate struct { - style *style.Styles -} - -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - s := d.style - i, ok := listItem.(item) - if !ok { - return - } - - ref := i.Short() - if i.Reference.IsTag() { - ref = s.RefItemTag.Render(ref) - } - ref = s.RefItemBranch.Render(ref) - refMaxWidth := m.Width() - - s.RefItemSelector.GetMarginLeft() - - s.RefItemSelector.GetWidth() - - s.RefItemInactive.GetMarginLeft() - ref = common.TruncateString(ref, refMaxWidth, "…") - if index == m.Index() { - fmt.Fprint(w, s.RefItemSelector.Render(">")+ - s.RefItemActive.Render(ref)) - } else { - fmt.Fprint(w, s.LogItemSelector.Render(" ")+ - s.RefItemInactive.Render(ref)) - } -} - -type Bubble struct { - repo common.GitRepo - list list.Model - style *style.Styles - width int - widthMargin int - height int - heightMargin int - ref *git.Reference -} - -func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { - head, err := repo.HEAD() - if err != nil { - return nil - } - l := list.NewModel([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin) - l.SetShowFilter(false) - l.SetShowHelp(false) - l.SetShowPagination(true) - l.SetShowStatusBar(false) - l.SetShowTitle(false) - l.SetFilteringEnabled(false) - l.DisableQuitKeybindings() - b := &Bubble{ - repo: repo, - style: styles, - width: width, - height: height, - widthMargin: widthMargin, - heightMargin: heightMargin, - list: l, - ref: head, - } - b.SetSize(width, height) - return b -} - -func (b *Bubble) SetBranch(ref *git.Reference) (tea.Model, tea.Cmd) { - b.ref = ref - return b, func() tea.Msg { - return RefMsg(ref) - } -} - -func (b *Bubble) reset() tea.Cmd { - cmd := b.updateItems() - b.SetSize(b.width, b.height) - return cmd -} - -func (b *Bubble) Init() tea.Cmd { - return nil -} - -func (b *Bubble) SetSize(width, height int) { - b.width = width - b.height = height - b.list.SetSize(width-b.widthMargin, height-b.heightMargin) - b.list.Styles.PaginationStyle = b.style.RefPaginator.Copy().Width(width - b.widthMargin) -} - -func (b *Bubble) Help() []common.HelpEntry { - return nil -} - -func (b *Bubble) updateItems() tea.Cmd { - its := make(items, 0) - tags := make(items, 0) - refs, err := b.repo.References() - if err != nil { - return func() tea.Msg { return common.ErrMsg{Err: err} } - } - for _, r := range refs { - if r.IsTag() { - tags = append(tags, item{r}) - } else if r.IsBranch() { - its = append(its, item{r}) - } - } - sort.Sort(its) - sort.Sort(tags) - its = append(its, tags...) - itt := make([]list.Item, len(its)) - for i, it := range its { - itt[i] = it - } - return b.list.SetItems(itt) -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - b.SetSize(msg.Width, msg.Height) - - case tea.KeyMsg: - switch msg.String() { - case "B": - return b, b.reset() - case "enter", "right", "l": - if b.list.Index() >= 0 { - ref := b.list.SelectedItem().(item).Reference - return b.SetBranch(ref) - } - } - } - - l, cmd := b.list.Update(msg) - b.list = l - cmds = append(cmds, cmd) - - return b, tea.Batch(cmds...) -} - -func (b *Bubble) View() string { - return b.list.View() -} diff --git a/tui/tree/bubble.go b/tui/tree/bubble.go deleted file mode 100644 index 74a5ccb3c..000000000 --- a/tui/tree/bubble.go +++ /dev/null @@ -1,341 +0,0 @@ -package tree - -import ( - "fmt" - "io" - "io/fs" - "path/filepath" - "strings" - - "github.com/charmbracelet/bubbles/list" - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/soft-serve/git" - "github.com/charmbracelet/soft-serve/internal/tui/style" - "github.com/charmbracelet/soft-serve/tui/common" - "github.com/charmbracelet/soft-serve/tui/refs" - vp "github.com/charmbracelet/soft-serve/tui/viewport" - "github.com/dustin/go-humanize" -) - -type fileMsg struct { - content string -} - -type sessionState int - -const ( - treeState sessionState = iota - fileState - errorState -) - -type item struct { - entry *git.TreeEntry -} - -func (i item) Name() string { - return i.entry.Name() -} - -func (i item) Mode() fs.FileMode { - return i.entry.Mode() -} - -func (i item) FilterValue() string { return i.Name() } - -type items []item - -func (cl items) Len() int { return len(cl) } -func (cl items) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } -func (cl items) Less(i, j int) bool { - if cl[i].entry.IsTree() && cl[j].entry.IsTree() { - return cl[i].Name() < cl[j].Name() - } else if cl[i].entry.IsTree() { - return true - } else if cl[j].entry.IsTree() { - return false - } else { - return cl[i].Name() < cl[j].Name() - } -} - -type itemDelegate struct { - style *style.Styles -} - -func (d itemDelegate) Height() int { return 1 } -func (d itemDelegate) Spacing() int { return 0 } -func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } -func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { - s := d.style - i, ok := listItem.(item) - if !ok { - return - } - - name := i.Name() - size := humanize.Bytes(uint64(i.entry.Size())) - if i.entry.IsTree() { - size = "" - name = s.TreeFileDir.Render(name) - } - var cs lipgloss.Style - mode := i.Mode() - if index == m.Index() { - cs = s.TreeItemActive - fmt.Fprint(w, s.TreeItemSelector.Render(">")) - } else { - cs = s.TreeItemInactive - fmt.Fprint(w, s.TreeItemSelector.Render(" ")) - } - leftMargin := s.TreeItemSelector.GetMarginLeft() + - s.TreeItemSelector.GetWidth() + - s.TreeFileMode.GetMarginLeft() + - s.TreeFileMode.GetWidth() + - cs.GetMarginLeft() - rightMargin := s.TreeFileSize.GetMarginLeft() + lipgloss.Width(size) - name = common.TruncateString(name, m.Width()-leftMargin-rightMargin, "…") - sizeStyle := s.TreeFileSize.Copy(). - Width(m.Width() - - leftMargin - - s.TreeFileSize.GetMarginLeft() - - lipgloss.Width(name)). - Align(lipgloss.Right) - fmt.Fprint(w, s.TreeFileMode.Render(mode.String())+ - cs.Render(name)+ - sizeStyle.Render(size)) -} - -type Bubble struct { - repo common.GitRepo - list list.Model - style *style.Styles - width int - widthMargin int - height int - heightMargin int - path string - state sessionState - error common.ErrMsg - fileViewport *vp.ViewportBubble - lastSelected []int - ref *git.Reference -} - -func NewBubble(repo common.GitRepo, styles *style.Styles, width, widthMargin, height, heightMargin int) *Bubble { - l := list.New([]list.Item{}, itemDelegate{styles}, width-widthMargin, height-heightMargin) - l.SetShowFilter(false) - l.SetShowHelp(false) - l.SetShowPagination(true) - l.SetShowStatusBar(false) - l.SetShowTitle(false) - l.SetFilteringEnabled(false) - l.DisableQuitKeybindings() - l.KeyMap.NextPage = common.NextPage - l.KeyMap.PrevPage = common.PrevPage - l.Styles.NoItems = styles.TreeNoItems - b := &Bubble{ - fileViewport: &vp.ViewportBubble{ - Viewport: &viewport.Model{}, - }, - repo: repo, - style: styles, - width: width, - height: height, - widthMargin: widthMargin, - heightMargin: heightMargin, - list: l, - state: treeState, - } - b.SetSize(width, height) - return b -} - -func (b *Bubble) reset() tea.Cmd { - b.path = "" - b.state = treeState - b.lastSelected = make([]int, 0) - cmd := b.updateItems() - return cmd -} - -func (b *Bubble) Init() tea.Cmd { - head, err := b.repo.HEAD() - if err != nil { - return func() tea.Msg { - return common.ErrMsg{Err: err} - } - } - b.ref = head - return nil -} - -func (b *Bubble) SetSize(width, height int) { - b.width = width - b.height = height - b.fileViewport.Viewport.Width = width - b.widthMargin - b.fileViewport.Viewport.Height = height - b.heightMargin - b.list.SetSize(width-b.widthMargin, height-b.heightMargin) - b.list.Styles.PaginationStyle = b.style.LogPaginator.Copy().Width(width - b.widthMargin) -} - -func (b *Bubble) Help() []common.HelpEntry { - return nil -} - -func (b *Bubble) updateItems() tea.Cmd { - files := make([]list.Item, 0) - dirs := make([]list.Item, 0) - t, err := b.repo.Tree(b.ref, b.path) - if err != nil { - return func() tea.Msg { return common.ErrMsg{Err: err} } - } - ents, err := t.Entries() - if err != nil { - return func() tea.Msg { return common.ErrMsg{Err: err} } - } - ents.Sort() - for _, e := range ents { - if e.IsTree() { - dirs = append(dirs, item{e}) - } else { - files = append(files, item{e}) - } - } - cmd := b.list.SetItems(append(dirs, files...)) - b.list.Select(0) - b.SetSize(b.width, b.height) - return cmd -} - -func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - cmds := make([]tea.Cmd, 0) - switch msg := msg.(type) { - case tea.WindowSizeMsg: - b.SetSize(msg.Width, msg.Height) - - case tea.KeyMsg: - if b.state == errorState { - ref, _ := b.repo.HEAD() - b.ref = ref - return b, tea.Batch(b.reset(), func() tea.Msg { - return ref - }) - } - - switch msg.String() { - case "F": - return b, b.reset() - case "enter", "right", "l": - if len(b.list.Items()) > 0 && b.state == treeState { - index := b.list.Index() - item := b.list.SelectedItem().(item) - mode := item.Mode() - b.path = filepath.Join(b.path, item.Name()) - if mode.IsDir() { - b.lastSelected = append(b.lastSelected, index) - cmds = append(cmds, b.updateItems()) - } else { - b.lastSelected = append(b.lastSelected, index) - cmds = append(cmds, b.loadFile(item)) - } - } - case "esc", "left", "h": - if b.state != treeState { - b.state = treeState - } - p := filepath.Dir(b.path) - b.path = p - cmds = append(cmds, b.updateItems()) - index := 0 - if len(b.lastSelected) > 0 { - index = b.lastSelected[len(b.lastSelected)-1] - b.lastSelected = b.lastSelected[:len(b.lastSelected)-1] - } - b.list.Select(index) - } - - case refs.RefMsg: - b.ref = msg - return b, b.reset() - - case common.ErrMsg: - b.error = msg - b.state = errorState - return b, nil - - case fileMsg: - content := b.renderFile(msg) - b.fileViewport.Viewport.SetContent(content) - b.fileViewport.Viewport.GotoTop() - b.state = fileState - } - - switch b.state { - case fileState: - rv, cmd := b.fileViewport.Update(msg) - b.fileViewport = rv.(*vp.ViewportBubble) - cmds = append(cmds, cmd) - case treeState: - l, cmd := b.list.Update(msg) - b.list = l - cmds = append(cmds, cmd) - } - - return b, tea.Batch(cmds...) -} - -func (b *Bubble) View() string { - switch b.state { - case treeState: - return b.list.View() - case errorState: - return b.error.ViewWithPrefix(b.style, "Error") - case fileState: - return b.fileViewport.View() - default: - return "" - } -} - -func (b *Bubble) loadFile(i item) tea.Cmd { - return func() tea.Msg { - f := i.entry.File() - if i.Mode().IsDir() || f == nil { - return common.ErrMsg{Err: common.ErrInvalidFile} - } - bin, err := f.IsBinary() - if err != nil { - return common.ErrMsg{Err: err} - } - if bin { - return common.ErrMsg{Err: common.ErrBinaryFile} - } - c, err := f.Bytes() - if err != nil { - return common.ErrMsg{Err: err} - } - return fileMsg{ - content: string(c), - } - } -} - -func (b *Bubble) renderFile(m fileMsg) string { - s := strings.Builder{} - c := m.content - if len(strings.Split(c, "\n")) > common.MaxDiffLines { - s.WriteString(b.style.TreeNoItems.Render(common.ErrFileTooLarge.Error())) - } else { - w := b.width - b.widthMargin - b.style.RepoBody.GetHorizontalFrameSize() - f, err := common.RenderFile(b.path, m.content, w) - if err != nil { - s.WriteString(err.Error()) - } else { - s.WriteString(f) - } - } - return b.style.TreeFileContent.Copy().Width(b.width - b.widthMargin).Render(s.String()) -} diff --git a/tui/viewport/viewport_patch.go b/tui/viewport/viewport_patch.go deleted file mode 100644 index 4163b5c40..000000000 --- a/tui/viewport/viewport_patch.go +++ /dev/null @@ -1,24 +0,0 @@ -package viewport - -import ( - "github.com/charmbracelet/bubbles/viewport" - tea "github.com/charmbracelet/bubbletea" -) - -type ViewportBubble struct { - Viewport *viewport.Model -} - -func (v *ViewportBubble) Init() tea.Cmd { - return nil -} - -func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - vp, cmd := v.Viewport.Update(msg) - v.Viewport = &vp - return v, cmd -} - -func (v *ViewportBubble) View() string { - return v.Viewport.View() -} diff --git a/ui/common/common.go b/ui/common/common.go new file mode 100644 index 000000000..f18d0298b --- /dev/null +++ b/ui/common/common.go @@ -0,0 +1,22 @@ +package common + +import ( + "github.com/aymanbagabas/go-osc52" + "github.com/charmbracelet/soft-serve/ui/keymap" + "github.com/charmbracelet/soft-serve/ui/styles" +) + +// Common is a struct all components should embed. +type Common struct { + Copy *osc52.Output + Styles *styles.Styles + KeyMap *keymap.KeyMap + Width int + Height int +} + +// SetSize sets the width and height of the common struct. +func (c *Common) SetSize(width, height int) { + c.Width = width + c.Height = height +} diff --git a/ui/common/component.go b/ui/common/component.go new file mode 100644 index 000000000..ed8b9bf05 --- /dev/null +++ b/ui/common/component.go @@ -0,0 +1,13 @@ +package common + +import ( + "github.com/charmbracelet/bubbles/help" + tea "github.com/charmbracelet/bubbletea" +) + +// Component represents a Bubble Tea model that implements a SetSize function. +type Component interface { + tea.Model + help.KeyMap + SetSize(width, height int) +} diff --git a/ui/common/error.go b/ui/common/error.go new file mode 100644 index 000000000..fe9729805 --- /dev/null +++ b/ui/common/error.go @@ -0,0 +1,13 @@ +package common + +import tea "github.com/charmbracelet/bubbletea" + +// ErrorMsg is a Bubble Tea message that represents an error. +type ErrorMsg error + +// ErrorCmd returns an ErrorMsg from error. +func ErrorCmd(err error) tea.Cmd { + return func() tea.Msg { + return ErrorMsg(err) + } +} diff --git a/ui/common/style.go b/ui/common/style.go new file mode 100644 index 000000000..1e2c911a7 --- /dev/null +++ b/ui/common/style.go @@ -0,0 +1,19 @@ +package common + +import ( + "github.com/charmbracelet/glamour" + gansi "github.com/charmbracelet/glamour/ansi" +) + +// StyleConfig returns the default Glamour style configuration. +func StyleConfig() gansi.StyleConfig { + noColor := "" + s := glamour.DarkStyleConfig + s.Document.StylePrimitive.Color = &noColor + s.CodeBlock.Chroma.Text.Color = &noColor + s.CodeBlock.Chroma.Name.Color = &noColor + // This fixes an issue with the default style config. For example + // highlighting empty spaces with red in Dockerfile type. + s.CodeBlock.Chroma.Error.BackgroundColor = &noColor + return s +} diff --git a/ui/common/utils.go b/ui/common/utils.go new file mode 100644 index 000000000..7c817a50e --- /dev/null +++ b/ui/common/utils.go @@ -0,0 +1,11 @@ +package common + +import "github.com/muesli/reflow/truncate" + +// TruncateString is a convenient wrapper around truncate.TruncateString. +func TruncateString(s string, max int) string { + if max < 0 { + max = 0 + } + return truncate.StringWithTail(s, uint(max), "…") +} diff --git a/ui/components/code/code.go b/ui/components/code/code.go new file mode 100644 index 000000000..9c832bc83 --- /dev/null +++ b/ui/components/code/code.go @@ -0,0 +1,259 @@ +package code + +import ( + "fmt" + "strings" + "sync" + + "github.com/alecthomas/chroma/lexers" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/ui/common" + vp "github.com/charmbracelet/soft-serve/ui/components/viewport" + "github.com/muesli/termenv" +) + +const ( + tabWidth = 4 +) + +var ( + lineDigitStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("239")) + lineBarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) +) + +// Code is a code snippet. +type Code struct { + *vp.Viewport + common common.Common + content string + extension string + renderContext gansi.RenderContext + renderMutex sync.Mutex + styleConfig gansi.StyleConfig + showLineNumber bool + + NoContentStyle lipgloss.Style + LineDigitStyle lipgloss.Style + LineBarStyle lipgloss.Style +} + +// New returns a new Code. +func New(c common.Common, content, extension string) *Code { + r := &Code{ + common: c, + content: content, + extension: extension, + Viewport: vp.New(c), + NoContentStyle: c.Styles.CodeNoContent.Copy(), + LineDigitStyle: lineDigitStyle, + LineBarStyle: lineBarStyle, + } + st := common.StyleConfig() + r.styleConfig = st + r.renderContext = gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: st, + }) + r.SetSize(c.Width, c.Height) + return r +} + +// SetShowLineNumber sets whether to show line numbers. +func (r *Code) SetShowLineNumber(show bool) { + r.showLineNumber = show +} + +// SetSize implements common.Component. +func (r *Code) SetSize(width, height int) { + r.common.SetSize(width, height) + r.Viewport.SetSize(width, height) +} + +// SetContent sets the content of the Code. +func (r *Code) SetContent(c, ext string) tea.Cmd { + r.content = c + r.extension = ext + return r.Init() +} + +// Init implements tea.Model. +func (r *Code) Init() tea.Cmd { + w := r.common.Width + c := r.content + if c == "" { + r.Viewport.Model.SetContent(r.NoContentStyle.String()) + return nil + } + f, err := r.renderFile(r.extension, c, w) + if err != nil { + return common.ErrorCmd(err) + } + r.Viewport.Model.SetContent(f) + return nil +} + +// Update implements tea.Model. +func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg.(type) { + case tea.WindowSizeMsg: + // Recalculate content width and line wrap. + cmds = append(cmds, r.Init()) + } + v, cmd := r.Viewport.Update(msg) + r.Viewport = v.(*vp.Viewport) + if cmd != nil { + cmds = append(cmds, cmd) + } + return r, tea.Batch(cmds...) +} + +// View implements tea.View. +func (r *Code) View() string { + return r.Viewport.View() +} + +// GotoTop moves the viewport to the top of the log. +func (r *Code) GotoTop() { + r.Viewport.GotoTop() +} + +// GotoBottom moves the viewport to the bottom of the log. +func (r *Code) GotoBottom() { + r.Viewport.GotoBottom() +} + +// HalfViewDown moves the viewport down by half the viewport height. +func (r *Code) HalfViewDown() { + r.Viewport.HalfViewDown() +} + +// HalfViewUp moves the viewport up by half the viewport height. +func (r *Code) HalfViewUp() { + r.Viewport.HalfViewUp() +} + +// ViewUp moves the viewport up by a page. +func (r *Code) ViewUp() []string { + return r.Viewport.ViewUp() +} + +// ViewDown moves the viewport down by a page. +func (r *Code) ViewDown() []string { + return r.Viewport.ViewDown() +} + +// LineUp moves the viewport up by the given number of lines. +func (r *Code) LineUp(n int) []string { + return r.Viewport.LineUp(n) +} + +// LineDown moves the viewport down by the given number of lines. +func (r *Code) LineDown(n int) []string { + return r.Viewport.LineDown(n) +} + +// ScrollPercent returns the viewport's scroll percentage. +func (r *Code) ScrollPercent() float64 { + return r.Viewport.ScrollPercent() +} + +func (r *Code) glamourize(w int, md string) (string, error) { + r.renderMutex.Lock() + defer r.renderMutex.Unlock() + if w > 120 { + w = 120 + } + tr, err := glamour.NewTermRenderer( + glamour.WithStyles(r.styleConfig), + glamour.WithWordWrap(w), + ) + + if err != nil { + return "", err + } + mdt, err := tr.Render(md) + if err != nil { + return "", err + } + return mdt, nil +} + +func (r *Code) renderFile(path, content string, width int) (string, error) { + // FIXME chroma & glamour might break wrapping when using tabs since tab + // width depends on the terminal. This is a workaround to replace tabs with + // 4-spaces. + content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth)) + lexer := lexers.Fallback + if path == "" { + lexer = lexers.Analyse(content) + } else { + lexer = lexers.Match(path) + } + lang := "" + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + var c string + if lang == "markdown" { + md, err := r.glamourize(width, content) + if err != nil { + return "", err + } + c = md + } else { + formatter := &gansi.CodeBlockElement{ + Code: content, + Language: lang, + } + s := strings.Builder{} + rc := r.renderContext + if r.showLineNumber { + st := common.StyleConfig() + var m uint + st.CodeBlock.Margin = &m + rc = gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: st, + }) + } + err := formatter.Render(&s, rc) + if err != nil { + return "", err + } + c = s.String() + if r.showLineNumber { + var ml int + c, ml = withLineNumber(c) + width -= ml + } + } + // Fix styling when after line breaks. + // https://github.com/muesli/reflow/issues/43 + // + // TODO: solve this upstream in Glamour/Reflow. + return lipgloss.NewStyle().Width(width).Render(c), nil +} + +func withLineNumber(s string) (string, int) { + lines := strings.Split(s, "\n") + // NB: len() is not a particularly safe way to count string width (because + // it's counting bytes instead of runes) but in this case it's okay + // because we're only dealing with digits, which are one byte each. + mll := len(fmt.Sprintf("%d", len(lines))) + for i, l := range lines { + digit := fmt.Sprintf("%*d", mll, i+1) + bar := "│" + digit = lineDigitStyle.Render(digit) + bar = lineBarStyle.Render(bar) + if i < len(lines)-1 || len(l) != 0 { + // If the final line was a newline we'll get an empty string for + // the final line, so drop the newline altogether. + lines[i] = fmt.Sprintf(" %s %s %s", digit, bar, l) + } + } + return strings.Join(lines, "\n"), mll +} diff --git a/ui/components/footer/footer.go b/ui/components/footer/footer.go new file mode 100644 index 000000000..6d724eb23 --- /dev/null +++ b/ui/components/footer/footer.go @@ -0,0 +1,85 @@ +package footer + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/ui/common" +) + +// Footer is a Bubble Tea model that displays help and other info. +type Footer struct { + common common.Common + help help.Model + keymap help.KeyMap +} + +// New creates a new Footer. +func New(c common.Common, keymap help.KeyMap) *Footer { + h := help.New() + h.Styles.ShortKey = c.Styles.HelpKey + h.Styles.ShortDesc = c.Styles.HelpValue + h.Styles.FullKey = c.Styles.HelpKey + h.Styles.FullDesc = c.Styles.HelpValue + f := &Footer{ + common: c, + help: h, + keymap: keymap, + } + f.SetSize(c.Width, c.Height) + return f +} + +// SetSize implements common.Component. +func (f *Footer) SetSize(width, height int) { + f.common.SetSize(width, height) + f.help.Width = width - + f.common.Styles.Footer.GetHorizontalFrameSize() +} + +// Init implements tea.Model. +func (f *Footer) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (f *Footer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return f, nil +} + +// View implements tea.Model. +func (f *Footer) View() string { + if f.keymap == nil { + return "" + } + s := f.common.Styles.Footer.Copy(). + Width(f.common.Width) + helpView := f.help.View(f.keymap) + return s.Render(helpView) +} + +// ShortHelp returns the short help key bindings. +func (f *Footer) ShortHelp() []key.Binding { + return f.keymap.ShortHelp() +} + +// FullHelp returns the full help key bindings. +func (f *Footer) FullHelp() [][]key.Binding { + return f.keymap.FullHelp() +} + +// ShowAll returns whether the full help is shown. +func (f *Footer) ShowAll() bool { + return f.help.ShowAll +} + +// SetShowAll sets whether the full help is shown. +func (f *Footer) SetShowAll(show bool) { + f.help.ShowAll = show +} + +// Height returns the height of the footer. +func (f *Footer) Height() int { + return lipgloss.Height(f.View()) +} diff --git a/ui/components/header/header.go b/ui/components/header/header.go new file mode 100644 index 000000000..b490ab1e3 --- /dev/null +++ b/ui/components/header/header.go @@ -0,0 +1,44 @@ +package header + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/ui/common" +) + +// Header represents a header component. +type Header struct { + common common.Common + text string +} + +// New creates a new header component. +func New(c common.Common, text string) *Header { + h := &Header{ + common: c, + text: text, + } + return h +} + +// SetSize implements common.Component. +func (h *Header) SetSize(width, height int) { + h.common.SetSize(width, height) +} + +// Init implements tea.Model. +func (h *Header) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (h *Header) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + return h, nil +} + +// View implements tea.Model. +func (h *Header) View() string { + s := h.common.Styles.Header.Copy().Width(h.common.Width) + return s.Render(strings.TrimSpace(h.text)) +} diff --git a/ui/components/selector/selector.go b/ui/components/selector/selector.go new file mode 100644 index 000000000..ed044990e --- /dev/null +++ b/ui/components/selector/selector.go @@ -0,0 +1,222 @@ +package selector + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/ui/common" +) + +// Selector is a list of items that can be selected. +type Selector struct { + list.Model + common common.Common + active int + filterState list.FilterState +} + +// IdentifiableItem is an item that can be identified by a string. Implements list.DefaultItem. +type IdentifiableItem interface { + list.DefaultItem + ID() string +} + +// ItemDelegate is a wrapper around list.ItemDelegate. +type ItemDelegate interface { + list.ItemDelegate +} + +// SelectMsg is a message that is sent when an item is selected. +type SelectMsg struct{ IdentifiableItem } + +// ActiveMsg is a message that is sent when an item is active but not selected. +type ActiveMsg struct{ IdentifiableItem } + +// New creates a new selector. +func New(common common.Common, items []IdentifiableItem, delegate ItemDelegate) *Selector { + itms := make([]list.Item, len(items)) + for i, item := range items { + itms[i] = item + } + l := list.New(itms, delegate, common.Width, common.Height) + s := &Selector{ + Model: l, + common: common, + } + s.SetSize(common.Width, common.Height) + return s +} + +// PerPage returns the number of items per page. +func (s *Selector) PerPage() int { + return s.Model.Paginator.PerPage +} + +// SetPage sets the current page. +func (s *Selector) SetPage(page int) { + s.Model.Paginator.Page = page +} + +// Page returns the current page. +func (s *Selector) Page() int { + return s.Model.Paginator.Page +} + +// TotalPages returns the total number of pages. +func (s *Selector) TotalPages() int { + return s.Model.Paginator.TotalPages +} + +// Select selects the item at the given index. +func (s *Selector) Select(index int) { + s.Model.Select(index) +} + +// SetShowTitle sets the show title flag. +func (s *Selector) SetShowTitle(show bool) { + s.Model.SetShowTitle(show) +} + +// SetShowHelp sets the show help flag. +func (s *Selector) SetShowHelp(show bool) { + s.Model.SetShowHelp(show) +} + +// SetShowStatusBar sets the show status bar flag. +func (s *Selector) SetShowStatusBar(show bool) { + s.Model.SetShowStatusBar(show) +} + +// DisableQuitKeybindings disables the quit keybindings. +func (s *Selector) DisableQuitKeybindings() { + s.Model.DisableQuitKeybindings() +} + +// SetShowFilter sets the show filter flag. +func (s *Selector) SetShowFilter(show bool) { + s.Model.SetShowFilter(show) +} + +// SetShowPagination sets the show pagination flag. +func (s *Selector) SetShowPagination(show bool) { + s.Model.SetShowPagination(show) +} + +// SetFilteringEnabled sets the filtering enabled flag. +func (s *Selector) SetFilteringEnabled(enabled bool) { + s.Model.SetFilteringEnabled(enabled) +} + +// SetSize implements common.Component. +func (s *Selector) SetSize(width, height int) { + s.common.SetSize(width, height) + s.Model.SetSize(width, height) +} + +// SetItems sets the items in the selector. +func (s *Selector) SetItems(items []IdentifiableItem) tea.Cmd { + its := make([]list.Item, len(items)) + for i, item := range items { + its[i] = item + } + return s.Model.SetItems(its) +} + +// Index returns the index of the selected item. +func (s *Selector) Index() int { + return s.Model.Index() +} + +// Init implements tea.Model. +func (s *Selector) Init() tea.Cmd { + return s.activeCmd +} + +// Update implements tea.Model. +func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.MouseMsg: + switch msg.Type { + case tea.MouseWheelUp: + s.Model.CursorUp() + case tea.MouseWheelDown: + s.Model.CursorDown() + } + case tea.KeyMsg: + filterState := s.Model.FilterState() + switch { + case key.Matches(msg, s.common.KeyMap.Help): + if filterState == list.Filtering { + return s, tea.Batch(cmds...) + } + case key.Matches(msg, s.common.KeyMap.Select): + if filterState != list.Filtering { + cmds = append(cmds, s.selectCmd) + } + } + case list.FilterMatchesMsg: + cmds = append(cmds, s.activeFilterCmd) + } + m, cmd := s.Model.Update(msg) + s.Model = m + if cmd != nil { + cmds = append(cmds, cmd) + } + // Track filter state and update active item when filter state changes. + filterState := s.Model.FilterState() + if s.filterState != filterState { + cmds = append(cmds, s.activeFilterCmd) + } + s.filterState = filterState + // Send ActiveMsg when index change. + if s.active != s.Model.Index() { + cmds = append(cmds, s.activeCmd) + } + s.active = s.Model.Index() + return s, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (s *Selector) View() string { + return s.Model.View() +} + +// SelectItem is a command that selects the currently active item. +func (s *Selector) SelectItem() tea.Msg { + return s.selectCmd() +} + +func (s *Selector) selectCmd() tea.Msg { + item := s.Model.SelectedItem() + i, ok := item.(IdentifiableItem) + if !ok { + return SelectMsg{} + } + return SelectMsg{i} +} + +func (s *Selector) activeCmd() tea.Msg { + item := s.Model.SelectedItem() + i, ok := item.(IdentifiableItem) + if !ok { + return ActiveMsg{} + } + return ActiveMsg{i} +} + +func (s *Selector) activeFilterCmd() tea.Msg { + // Here we use VisibleItems because when list.FilterMatchesMsg is sent, + // VisibleItems is the only way to get the list of filtered items. The list + // bubble should export something like list.FilterMatchesMsg.Items(). + items := s.Model.VisibleItems() + if len(items) == 0 { + return nil + } + item := items[0] + i, ok := item.(IdentifiableItem) + if !ok { + return nil + } + return ActiveMsg{i} +} diff --git a/ui/components/statusbar/statusbar.go b/ui/components/statusbar/statusbar.go new file mode 100644 index 000000000..92b27914b --- /dev/null +++ b/ui/components/statusbar/statusbar.go @@ -0,0 +1,85 @@ +package statusbar + +import ( + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/muesli/reflow/truncate" +) + +// StatusBarMsg is a message sent to the status bar. +type StatusBarMsg struct { + Key string + Value string + Info string + Branch string +} + +// StatusBar is a status bar model. +type StatusBar struct { + common common.Common + msg StatusBarMsg +} + +// Model is an interface that supports setting the status bar information. +type Model interface { + StatusBarValue() string + StatusBarInfo() string +} + +// New creates a new status bar component. +func New(c common.Common) *StatusBar { + s := &StatusBar{ + common: c, + } + return s +} + +// SetSize implements common.Component. +func (s *StatusBar) SetSize(width, height int) { + s.common.Width = width + s.common.Height = height +} + +// Init implements tea.Model. +func (s *StatusBar) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (s *StatusBar) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case StatusBarMsg: + s.msg = msg + } + return s, nil +} + +// View implements tea.Model. +func (s *StatusBar) View() string { + st := s.common.Styles + w := lipgloss.Width + help := st.StatusBarHelp.Render("? Help") + key := st.StatusBarKey.Render(s.msg.Key) + info := "" + if s.msg.Info != "" { + info = st.StatusBarInfo.Render(s.msg.Info) + } + branch := st.StatusBarBranch.Render(s.msg.Branch) + maxWidth := s.common.Width - w(key) - w(info) - w(branch) - w(help) + v := truncate.StringWithTail(s.msg.Value, uint(maxWidth-st.StatusBarValue.GetHorizontalFrameSize()), "…") + value := st.StatusBarValue. + Width(maxWidth). + Render(v) + + return lipgloss.NewStyle().MaxWidth(s.common.Width). + Render( + lipgloss.JoinHorizontal(lipgloss.Top, + key, + value, + info, + branch, + help, + ), + ) +} diff --git a/ui/components/tabs/tabs.go b/ui/components/tabs/tabs.go new file mode 100644 index 000000000..4fdb9b1d7 --- /dev/null +++ b/ui/components/tabs/tabs.go @@ -0,0 +1,101 @@ +package tabs + +import ( + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/ui/common" +) + +// SelectTabMsg is a message that contains the index of the tab to select. +type SelectTabMsg int + +// ActiveTabMsg is a message that contains the index of the current active tab. +type ActiveTabMsg int + +// Tabs is bubbletea component that displays a list of tabs. +type Tabs struct { + common common.Common + tabs []string + activeTab int + TabSeparator lipgloss.Style + TabInactive lipgloss.Style + TabActive lipgloss.Style +} + +// New creates a new Tabs component. +func New(c common.Common, tabs []string) *Tabs { + r := &Tabs{ + common: c, + tabs: tabs, + activeTab: 0, + TabSeparator: c.Styles.TabSeparator, + TabInactive: c.Styles.TabInactive, + TabActive: c.Styles.TabActive, + } + return r +} + +// SetSize implements common.Component. +func (t *Tabs) SetSize(width, height int) { + t.common.SetSize(width, height) +} + +// Init implements tea.Model. +func (t *Tabs) Init() tea.Cmd { + t.activeTab = 0 + return nil +} + +// Update implements tea.Model. +func (t *Tabs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "tab": + t.activeTab = (t.activeTab + 1) % len(t.tabs) + cmds = append(cmds, t.activeTabCmd) + case "shift+tab": + t.activeTab = (t.activeTab - 1 + len(t.tabs)) % len(t.tabs) + cmds = append(cmds, t.activeTabCmd) + } + case SelectTabMsg: + tab := int(msg) + if tab >= 0 && tab < len(t.tabs) { + t.activeTab = int(msg) + } + } + return t, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (t *Tabs) View() string { + s := strings.Builder{} + sep := t.TabSeparator + for i, tab := range t.tabs { + style := t.TabInactive.Copy() + if i == t.activeTab { + style = t.TabActive.Copy() + } + s.WriteString(style.Render(tab)) + if i != len(t.tabs)-1 { + s.WriteString(sep.String()) + } + } + return lipgloss.NewStyle(). + MaxWidth(t.common.Width). + Render(s.String()) +} + +func (t *Tabs) activeTabCmd() tea.Msg { + return ActiveTabMsg(t.activeTab) +} + +// SelectTabCmd is a bubbletea command that selects the tab at the given index. +func SelectTabCmd(tab int) tea.Cmd { + return func() tea.Msg { + return SelectTabMsg(tab) + } +} diff --git a/ui/components/viewport/viewport.go b/ui/components/viewport/viewport.go new file mode 100644 index 000000000..87789a9a0 --- /dev/null +++ b/ui/components/viewport/viewport.go @@ -0,0 +1,97 @@ +package viewport + +import ( + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/ui/common" +) + +// Viewport represents a viewport component. +type Viewport struct { + common common.Common + *viewport.Model +} + +// New returns a new Viewport. +func New(c common.Common) *Viewport { + vp := viewport.New(c.Width, c.Height) + vp.MouseWheelEnabled = true + return &Viewport{ + common: c, + Model: &vp, + } +} + +// SetSize implements common.Component. +func (v *Viewport) SetSize(width, height int) { + v.common.SetSize(width, height) + v.Model.Width = width + v.Model.Height = height +} + +// Init implements tea.Model. +func (v *Viewport) Init() tea.Cmd { + return nil +} + +// Update implements tea.Model. +func (v *Viewport) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + vp, cmd := v.Model.Update(msg) + v.Model = &vp + return v, cmd +} + +// View implements tea.Model. +func (v *Viewport) View() string { + return v.Model.View() +} + +// SetContent sets the viewport's content. +func (v *Viewport) SetContent(content string) { + v.Model.SetContent(content) +} + +// GotoTop moves the viewport to the top of the log. +func (v *Viewport) GotoTop() { + v.Model.GotoTop() +} + +// GotoBottom moves the viewport to the bottom of the log. +func (v *Viewport) GotoBottom() { + v.Model.GotoBottom() +} + +// HalfViewDown moves the viewport down by half the viewport height. +func (v *Viewport) HalfViewDown() { + v.Model.HalfViewDown() +} + +// HalfViewUp moves the viewport up by half the viewport height. +func (v *Viewport) HalfViewUp() { + v.Model.HalfViewUp() +} + +// ViewUp moves the viewport up by a page. +func (v *Viewport) ViewUp() []string { + return v.Model.ViewUp() +} + +// ViewDown moves the viewport down by a page. +func (v *Viewport) ViewDown() []string { + return v.Model.ViewDown() +} + +// LineUp moves the viewport up by the given number of lines. +func (v *Viewport) LineUp(n int) []string { + return v.Model.LineUp(n) +} + +// LineDown moves the viewport down by the given number of lines. +func (v *Viewport) LineDown(n int) []string { + return v.Model.LineDown(n) +} + +// ScrollPercent returns the viewport's scroll percentage. +func (v *Viewport) ScrollPercent() float64 { + return v.Model.ScrollPercent() +} diff --git a/ui/git.go b/ui/git.go new file mode 100644 index 000000000..e4dcf80a2 --- /dev/null +++ b/ui/git.go @@ -0,0 +1,25 @@ +package ui + +import ( + "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/ui/git" +) + +// source is a wrapper around config.RepoSource that implements git.GitRepoSource. +type source struct { + *config.RepoSource +} + +// GetRepo implements git.GitRepoSource. +func (s *source) GetRepo(name string) (git.GitRepo, error) { + return s.RepoSource.GetRepo(name) +} + +// AllRepos implements git.GitRepoSource. +func (s *source) AllRepos() []git.GitRepo { + rs := make([]git.GitRepo, 0) + for _, r := range s.RepoSource.AllRepos() { + rs = append(rs, r) + } + return rs +} diff --git a/ui/git/git.go b/ui/git/git.go new file mode 100644 index 000000000..c51fee1cd --- /dev/null +++ b/ui/git/git.go @@ -0,0 +1,42 @@ +package git + +import ( + "errors" + "fmt" + + "github.com/charmbracelet/soft-serve/git" +) + +// ErrMissingRepo indicates that the requested repository could not be found. +var ErrMissingRepo = errors.New("missing repo") + +// GitRepo is an interface for Git repositories. +type GitRepo interface { + Repo() string + Name() string + Description() string + Readme() (string, string) + HEAD() (*git.Reference, error) + Commit(string) (*git.Commit, error) + CommitsByPage(*git.Reference, int, int) (git.Commits, error) + CountCommits(*git.Reference) (int64, error) + Diff(*git.Commit) (*git.Diff, error) + References() ([]*git.Reference, error) + Tree(*git.Reference, string) (*git.Tree, error) + IsPrivate() bool +} + +// GitRepoSource is an interface for Git repository factory. +type GitRepoSource interface { + GetRepo(string) (GitRepo, error) + AllRepos() []GitRepo +} + +// RepoURL returns the URL of the repository. +func RepoURL(host string, port int, name string) string { + p := "" + if port != 22 { + p += fmt.Sprintf(":%d", port) + } + return fmt.Sprintf("git clone ssh://%s/%s", host+p, name) +} diff --git a/ui/keymap/keymap.go b/ui/keymap/keymap.go new file mode 100644 index 000000000..fddfb11ca --- /dev/null +++ b/ui/keymap/keymap.go @@ -0,0 +1,205 @@ +package keymap + +import "github.com/charmbracelet/bubbles/key" + +// KeyMap is a map of key bindings for the UI. +type KeyMap struct { + Quit key.Binding + Up key.Binding + Down key.Binding + UpDown key.Binding + LeftRight key.Binding + Arrows key.Binding + Select key.Binding + Section key.Binding + Back key.Binding + PrevPage key.Binding + NextPage key.Binding + Help key.Binding + + SelectItem key.Binding + BackItem key.Binding + + Copy key.Binding +} + +// DefaultKeyMap returns the default key map. +func DefaultKeyMap() *KeyMap { + km := new(KeyMap) + + km.Quit = key.NewBinding( + key.WithKeys( + "q", + "ctrl+c", + ), + key.WithHelp( + "q", + "quit", + ), + ) + + km.Up = key.NewBinding( + key.WithKeys( + "up", + "k", + ), + key.WithHelp( + "↑", + "up", + ), + ) + + km.Down = key.NewBinding( + key.WithKeys( + "down", + "j", + ), + key.WithHelp( + "↓", + "down", + ), + ) + + km.UpDown = key.NewBinding( + key.WithKeys( + "up", + "down", + "k", + "j", + ), + key.WithHelp( + "↑↓", + "navigate", + ), + ) + + km.LeftRight = key.NewBinding( + key.WithKeys( + "left", + "h", + "right", + "l", + ), + key.WithHelp( + "←→", + "navigate", + ), + ) + + km.Arrows = key.NewBinding( + key.WithKeys( + "up", + "right", + "down", + "left", + "k", + "j", + "h", + "l", + ), + key.WithHelp( + "↑←↓→", + "navigate", + ), + ) + + km.Select = key.NewBinding( + key.WithKeys( + "enter", + ), + key.WithHelp( + "enter", + "select", + ), + ) + + km.Section = key.NewBinding( + key.WithKeys( + "tab", + "shift+tab", + ), + key.WithHelp( + "tab", + "section", + ), + ) + + km.Back = key.NewBinding( + key.WithKeys( + "esc", + ), + key.WithHelp( + "esc", + "back", + ), + ) + + km.PrevPage = key.NewBinding( + key.WithKeys( + "pgup", + "b", + "u", + ), + key.WithHelp( + "pgup", + "prev page", + ), + ) + + km.NextPage = key.NewBinding( + key.WithKeys( + "pgdown", + "f", + "d", + ), + key.WithHelp( + "pgdn", + "next page", + ), + ) + + km.Help = key.NewBinding( + key.WithKeys( + "?", + ), + key.WithHelp( + "?", + "toggle help", + ), + ) + + km.SelectItem = key.NewBinding( + key.WithKeys( + "l", + "right", + ), + key.WithHelp( + "→", + "select", + ), + ) + + km.BackItem = key.NewBinding( + key.WithKeys( + "h", + "left", + ), + key.WithHelp( + "←", + "back", + ), + ) + + km.Copy = key.NewBinding( + key.WithKeys( + "c", + "ctrl+c", + ), + key.WithHelp( + "c", + "copy text", + ), + ) + + return km +} diff --git a/ui/pages/repo/files.go b/ui/pages/repo/files.go new file mode 100644 index 000000000..065c491f7 --- /dev/null +++ b/ui/pages/repo/files.go @@ -0,0 +1,390 @@ +package repo + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/alecthomas/chroma/lexers" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/code" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/git" +) + +type filesView int + +const ( + filesViewFiles filesView = iota + filesViewContent +) + +var ( + errNoFileSelected = errors.New("no file selected") + errBinaryFile = errors.New("binary file") + errFileTooLarge = errors.New("file is too large") + errInvalidFile = errors.New("invalid file") +) + +var ( + lineNo = key.NewBinding( + key.WithKeys("l"), + key.WithHelp("l", "toggle line numbers"), + ) +) + +// FileItemsMsg is a message that contains a list of files. +type FileItemsMsg []selector.IdentifiableItem + +// FileContentMsg is a message that contains the content of a file. +type FileContentMsg struct { + content string + ext string +} + +// Files is the model for the files view. +type Files struct { + common common.Common + selector *selector.Selector + ref *ggit.Reference + activeView filesView + repo git.GitRepo + code *code.Code + path string + currentItem *FileItem + currentContent FileContentMsg + lastSelected []int + lineNumber bool +} + +// NewFiles creates a new files model. +func NewFiles(common common.Common) *Files { + f := &Files{ + common: common, + code: code.New(common, "", ""), + activeView: filesViewFiles, + lastSelected: make([]int, 0), + lineNumber: true, + } + selector := selector.New(common, []selector.IdentifiableItem{}, FileItemDelegate{&common}) + selector.SetShowFilter(false) + selector.SetShowHelp(false) + selector.SetShowPagination(false) + selector.SetShowStatusBar(false) + selector.SetShowTitle(false) + selector.SetFilteringEnabled(false) + selector.DisableQuitKeybindings() + selector.KeyMap.NextPage = common.KeyMap.NextPage + selector.KeyMap.PrevPage = common.KeyMap.PrevPage + f.selector = selector + f.code.SetShowLineNumber(f.lineNumber) + return f +} + +// SetSize implements common.Component. +func (f *Files) SetSize(width, height int) { + f.common.SetSize(width, height) + f.selector.SetSize(width, height) + f.code.SetSize(width, height) +} + +// ShortHelp implements help.KeyMap. +func (f *Files) ShortHelp() []key.Binding { + k := f.selector.KeyMap + switch f.activeView { + case filesViewFiles: + copyKey := f.common.KeyMap.Copy + copyKey.SetHelp("c", "copy name") + return []key.Binding{ + f.common.KeyMap.SelectItem, + f.common.KeyMap.BackItem, + k.CursorUp, + k.CursorDown, + copyKey, + } + case filesViewContent: + copyKey := f.common.KeyMap.Copy + copyKey.SetHelp("c", "copy content") + b := []key.Binding{ + f.common.KeyMap.UpDown, + f.common.KeyMap.BackItem, + copyKey, + } + lexer := lexers.Match(f.currentContent.ext) + lang := "" + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + if lang != "markdown" { + b = append(b, lineNo) + } + return b + default: + return []key.Binding{} + } +} + +// FullHelp implements help.KeyMap. +func (f *Files) FullHelp() [][]key.Binding { + b := make([][]key.Binding, 0) + switch f.activeView { + case filesViewFiles: + copyKey := f.common.KeyMap.Copy + copyKey.SetHelp("c", "copy name") + k := f.selector.KeyMap + b = append(b, []key.Binding{ + f.common.KeyMap.SelectItem, + f.common.KeyMap.BackItem, + }) + b = append(b, [][]key.Binding{ + { + k.CursorUp, + k.CursorDown, + k.NextPage, + k.PrevPage, + }, + { + k.GoToStart, + k.GoToEnd, + copyKey, + }, + }...) + case filesViewContent: + copyKey := f.common.KeyMap.Copy + copyKey.SetHelp("c", "copy content") + k := f.code.KeyMap + b = append(b, []key.Binding{ + f.common.KeyMap.BackItem, + }) + b = append(b, [][]key.Binding{ + { + k.PageDown, + k.PageUp, + k.HalfPageDown, + k.HalfPageUp, + }, + }...) + lc := []key.Binding{ + k.Down, + k.Up, + copyKey, + } + lexer := lexers.Match(f.currentContent.ext) + lang := "" + if lexer != nil && lexer.Config() != nil { + lang = lexer.Config().Name + } + if lang != "markdown" { + lc = append(lc, lineNo) + } + b = append(b, lc) + } + return b +} + +// Init implements tea.Model. +func (f *Files) Init() tea.Cmd { + f.path = "" + f.currentItem = nil + f.activeView = filesViewFiles + f.lastSelected = make([]int, 0) + f.selector.Select(0) + return f.updateFilesCmd +} + +// Update implements tea.Model. +func (f *Files) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case RepoMsg: + f.repo = git.GitRepo(msg) + cmds = append(cmds, f.Init()) + case RefMsg: + f.ref = msg + cmds = append(cmds, f.Init()) + case FileItemsMsg: + cmds = append(cmds, + f.selector.SetItems(msg), + updateStatusBarCmd, + ) + case FileContentMsg: + f.activeView = filesViewContent + f.currentContent = msg + f.code.SetContent(msg.content, msg.ext) + f.code.GotoTop() + cmds = append(cmds, updateStatusBarCmd) + case selector.SelectMsg: + switch sel := msg.IdentifiableItem.(type) { + case FileItem: + f.currentItem = &sel + f.path = filepath.Join(f.path, sel.entry.Name()) + if sel.entry.IsTree() { + cmds = append(cmds, f.selectTreeCmd) + } else { + cmds = append(cmds, f.selectFileCmd) + } + } + case tea.KeyMsg: + switch f.activeView { + case filesViewFiles: + switch msg.String() { + case "l", "right": + cmds = append(cmds, f.selector.SelectItem) + case "h", "left": + cmds = append(cmds, f.deselectItemCmd) + } + case filesViewContent: + keyStr := msg.String() + switch { + case keyStr == "h", keyStr == "left": + cmds = append(cmds, f.deselectItemCmd) + case key.Matches(msg, f.common.KeyMap.Copy): + f.common.Copy.Copy(f.currentContent.content) + case key.Matches(msg, lineNo): + f.lineNumber = !f.lineNumber + f.code.SetShowLineNumber(f.lineNumber) + cmds = append(cmds, f.code.SetContent(f.currentContent.content, f.currentContent.ext)) + } + } + case tea.WindowSizeMsg: + switch f.activeView { + case filesViewFiles: + if f.repo != nil { + cmds = append(cmds, f.updateFilesCmd) + } + case filesViewContent: + if f.currentContent.content != "" { + m, cmd := f.code.Update(msg) + f.code = m.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + } + } + switch f.activeView { + case filesViewFiles: + m, cmd := f.selector.Update(msg) + f.selector = m.(*selector.Selector) + if cmd != nil { + cmds = append(cmds, cmd) + } + case filesViewContent: + m, cmd := f.code.Update(msg) + f.code = m.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return f, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (f *Files) View() string { + switch f.activeView { + case filesViewFiles: + return f.selector.View() + case filesViewContent: + return f.code.View() + default: + return "" + } +} + +// StatusBarValue returns the status bar value. +func (f *Files) StatusBarValue() string { + p := f.path + if p == "." { + return "" + } + return p +} + +// StatusBarInfo returns the status bar info. +func (f *Files) StatusBarInfo() string { + switch f.activeView { + case filesViewFiles: + return fmt.Sprintf("# %d/%d", f.selector.Index()+1, len(f.selector.VisibleItems())) + case filesViewContent: + return fmt.Sprintf("☰ %.f%%", f.code.ScrollPercent()*100) + default: + return "" + } +} + +func (f *Files) updateFilesCmd() tea.Msg { + files := make([]selector.IdentifiableItem, 0) + dirs := make([]selector.IdentifiableItem, 0) + if f.ref == nil { + return common.ErrorMsg(errNoRef) + } + t, err := f.repo.Tree(f.ref, f.path) + if err != nil { + return common.ErrorMsg(err) + } + ents, err := t.Entries() + if err != nil { + return common.ErrorMsg(err) + } + ents.Sort() + for _, e := range ents { + if e.IsTree() { + dirs = append(dirs, FileItem{entry: e}) + } else { + files = append(files, FileItem{entry: e}) + } + } + return FileItemsMsg(append(dirs, files...)) +} + +func (f *Files) selectTreeCmd() tea.Msg { + if f.currentItem != nil && f.currentItem.entry.IsTree() { + f.lastSelected = append(f.lastSelected, f.selector.Index()) + f.selector.Select(0) + return f.updateFilesCmd() + } + return common.ErrorMsg(errNoFileSelected) +} + +func (f *Files) selectFileCmd() tea.Msg { + i := f.currentItem + if i != nil && !i.entry.IsTree() { + fi := i.entry.File() + if i.Mode().IsDir() || f == nil { + return common.ErrorMsg(errInvalidFile) + } + bin, err := fi.IsBinary() + if err != nil { + f.path = filepath.Dir(f.path) + return common.ErrorMsg(err) + } + if bin { + f.path = filepath.Dir(f.path) + return common.ErrorMsg(errBinaryFile) + } + c, err := fi.Bytes() + if err != nil { + f.path = filepath.Dir(f.path) + return common.ErrorMsg(err) + } + f.lastSelected = append(f.lastSelected, f.selector.Index()) + return FileContentMsg{string(c), i.entry.Name()} + } + return common.ErrorMsg(errNoFileSelected) +} + +func (f *Files) deselectItemCmd() tea.Msg { + f.path = filepath.Dir(f.path) + f.activeView = filesViewFiles + msg := f.updateFilesCmd() + index := 0 + if len(f.lastSelected) > 0 { + index = f.lastSelected[len(f.lastSelected)-1] + f.lastSelected = f.lastSelected[:len(f.lastSelected)-1] + } + f.selector.Select(index) + return msg +} diff --git a/ui/pages/repo/filesitem.go b/ui/pages/repo/filesitem.go new file mode 100644 index 000000000..9f94b455c --- /dev/null +++ b/ui/pages/repo/filesitem.go @@ -0,0 +1,146 @@ +package repo + +import ( + "fmt" + "io" + "io/fs" + "strings" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/dustin/go-humanize" +) + +// FileItem is a list item for a file. +type FileItem struct { + entry *git.TreeEntry +} + +// ID returns the ID of the file item. +func (i FileItem) ID() string { + return i.entry.Name() +} + +// Title returns the title of the file item. +func (i FileItem) Title() string { + return i.entry.Name() +} + +// Description returns the description of the file item. +func (i FileItem) Description() string { + return "" +} + +// Mode returns the mode of the file item. +func (i FileItem) Mode() fs.FileMode { + return i.entry.Mode() +} + +// FilterValue implements list.Item. +func (i FileItem) FilterValue() string { return i.Title() } + +// FileItems is a list of file items. +type FileItems []FileItem + +// Len implements sort.Interface. +func (cl FileItems) Len() int { return len(cl) } + +// Swap implements sort.Interface. +func (cl FileItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } + +// Less implements sort.Interface. +func (cl FileItems) Less(i, j int) bool { + if cl[i].entry.IsTree() && cl[j].entry.IsTree() { + return cl[i].Title() < cl[j].Title() + } else if cl[i].entry.IsTree() { + return true + } else if cl[j].entry.IsTree() { + return false + } else { + return cl[i].Title() < cl[j].Title() + } +} + +// FileItemDelegate is the delegate for the file item list. +type FileItemDelegate struct { + common *common.Common +} + +// Height returns the height of the file item list. Implements list.ItemDelegate. +func (d FileItemDelegate) Height() int { return 1 } + +// Spacing returns the spacing of the file item list. Implements list.ItemDelegate. +func (d FileItemDelegate) Spacing() int { return 0 } + +// Update implements list.ItemDelegate. +func (d FileItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + idx := m.Index() + item, ok := m.SelectedItem().(FileItem) + if !ok { + return nil + } + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, d.common.KeyMap.Copy): + d.common.Copy.Copy(item.Title()) + return m.SetItem(idx, item) + } + } + return nil +} + +// Render implements list.ItemDelegate. +func (d FileItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + s := d.common.Styles + i, ok := listItem.(FileItem) + if !ok { + return + } + + name := i.Title() + size := humanize.Bytes(uint64(i.entry.Size())) + size = strings.ReplaceAll(size, " ", "") + sizeLen := lipgloss.Width(size) + if i.entry.IsTree() { + size = strings.Repeat(" ", sizeLen) + name = s.TreeFileDir.Render(name) + } + var cs lipgloss.Style + mode := i.Mode() + if index == m.Index() { + cs = s.TreeItemActive + fmt.Fprint(w, s.TreeItemSelector.Render(">")) + } else { + cs = s.TreeItemInactive + fmt.Fprint(w, s.TreeItemSelector.Render(" ")) + } + sizeStyle := s.TreeFileSize.Copy(). + Width(8). + Align(lipgloss.Right). + MarginLeft(1) + leftMargin := s.TreeItemSelector.GetMarginLeft() + + s.TreeItemSelector.GetWidth() + + s.TreeFileMode.GetMarginLeft() + + s.TreeFileMode.GetWidth() + + cs.GetMarginLeft() + + sizeStyle.GetHorizontalFrameSize() + name = common.TruncateString(name, m.Width()-leftMargin) + name = cs.Render(name) + size = sizeStyle.Render(size) + modeStr := s.TreeFileMode.Render(mode.String()) + truncate := lipgloss.NewStyle().MaxWidth(m.Width() - + s.TreeItemSelector.GetHorizontalFrameSize() - + s.TreeItemSelector.GetWidth()) + fmt.Fprint(w, + truncate.Render(fmt.Sprintf("%s%s%s", + modeStr, + size, + name, + )), + ) +} diff --git a/ui/pages/repo/log.go b/ui/pages/repo/log.go new file mode 100644 index 000000000..ffd5fe202 --- /dev/null +++ b/ui/pages/repo/log.go @@ -0,0 +1,476 @@ +package repo + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + gansi "github.com/charmbracelet/glamour/ansi" + "github.com/charmbracelet/lipgloss" + ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/components/viewport" + "github.com/charmbracelet/soft-serve/ui/git" + "github.com/muesli/reflow/wrap" + "github.com/muesli/termenv" +) + +var ( + waitBeforeLoading = time.Millisecond * 100 +) + +type logView int + +const ( + logViewCommits logView = iota + logViewDiff +) + +// LogCountMsg is a message that contains the number of commits in a repo. +type LogCountMsg int64 + +// LogItemsMsg is a message that contains a slice of LogItem. +type LogItemsMsg []selector.IdentifiableItem + +// LogCommitMsg is a message that contains a git commit. +type LogCommitMsg *ggit.Commit + +// LogDiffMsg is a message that contains a git diff. +type LogDiffMsg *ggit.Diff + +// Log is a model that displays a list of commits and their diffs. +type Log struct { + common common.Common + selector *selector.Selector + vp *viewport.Viewport + activeView logView + repo git.GitRepo + ref *ggit.Reference + count int64 + nextPage int + activeCommit *ggit.Commit + selectedCommit *ggit.Commit + currentDiff *ggit.Diff + loadingTime time.Time + loading bool + spinner spinner.Model +} + +// NewLog creates a new Log model. +func NewLog(common common.Common) *Log { + l := &Log{ + common: common, + vp: viewport.New(common), + activeView: logViewCommits, + } + selector := selector.New(common, []selector.IdentifiableItem{}, LogItemDelegate{&common}) + selector.SetShowFilter(false) + selector.SetShowHelp(false) + selector.SetShowPagination(false) + selector.SetShowStatusBar(false) + selector.SetShowTitle(false) + selector.SetFilteringEnabled(false) + selector.DisableQuitKeybindings() + selector.KeyMap.NextPage = common.KeyMap.NextPage + selector.KeyMap.PrevPage = common.KeyMap.PrevPage + l.selector = selector + s := spinner.New() + s.Spinner = spinner.Dot + s.Style = common.Styles.Spinner + l.spinner = s + return l +} + +// SetSize implements common.Component. +func (l *Log) SetSize(width, height int) { + l.common.SetSize(width, height) + l.selector.SetSize(width, height) + l.vp.SetSize(width, height) +} + +// ShortHelp implements help.KeyMap. +func (l *Log) ShortHelp() []key.Binding { + switch l.activeView { + case logViewCommits: + copyKey := l.common.KeyMap.Copy + copyKey.SetHelp("c", "copy hash") + return []key.Binding{ + l.common.KeyMap.UpDown, + l.common.KeyMap.SelectItem, + copyKey, + } + case logViewDiff: + return []key.Binding{ + l.common.KeyMap.UpDown, + l.common.KeyMap.BackItem, + } + default: + return []key.Binding{} + } +} + +// FullHelp implements help.KeyMap. +func (l *Log) FullHelp() [][]key.Binding { + k := l.selector.KeyMap + b := make([][]key.Binding, 0) + switch l.activeView { + case logViewCommits: + copyKey := l.common.KeyMap.Copy + copyKey.SetHelp("c", "copy hash") + b = append(b, []key.Binding{ + l.common.KeyMap.SelectItem, + l.common.KeyMap.BackItem, + }) + b = append(b, [][]key.Binding{ + { + copyKey, + k.CursorUp, + k.CursorDown, + }, + { + k.NextPage, + k.PrevPage, + k.GoToStart, + k.GoToEnd, + }, + }...) + case logViewDiff: + k := l.vp.KeyMap + b = append(b, []key.Binding{ + l.common.KeyMap.BackItem, + }) + b = append(b, [][]key.Binding{ + { + k.PageDown, + k.PageUp, + k.HalfPageDown, + k.HalfPageUp, + }, + { + k.Down, + k.Up, + }, + }...) + } + return b +} + +func (l *Log) startLoading() tea.Cmd { + l.loadingTime = time.Now() + l.loading = true + return l.spinner.Tick +} + +func (l *Log) stopLoading() tea.Cmd { + l.loading = false + return updateStatusBarCmd +} + +// Init implements tea.Model. +func (l *Log) Init() tea.Cmd { + l.activeView = logViewCommits + l.nextPage = 0 + l.count = 0 + l.activeCommit = nil + l.selectedCommit = nil + l.selector.Select(0) + return tea.Batch( + l.updateCommitsCmd, + // start loading on init + l.startLoading(), + ) +} + +// Update implements tea.Model. +func (l *Log) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case RepoMsg: + l.repo = git.GitRepo(msg) + cmds = append(cmds, l.Init()) + case RefMsg: + l.ref = msg + cmds = append(cmds, l.Init()) + case LogCountMsg: + l.count = int64(msg) + case LogItemsMsg: + cmds = append(cmds, + l.selector.SetItems(msg), + // stop loading after receiving items + l.stopLoading(), + ) + l.selector.SetPage(l.nextPage) + l.SetSize(l.common.Width, l.common.Height) + i := l.selector.SelectedItem() + if i != nil { + l.activeCommit = i.(LogItem).Commit + } + case tea.KeyMsg, tea.MouseMsg: + switch l.activeView { + case logViewCommits: + switch key := msg.(type) { + case tea.KeyMsg: + switch key.String() { + case "l", "right": + cmds = append(cmds, l.selector.SelectItem) + } + } + // This is a hack for loading commits on demand based on list.Pagination. + curPage := l.selector.Page() + s, cmd := l.selector.Update(msg) + m := s.(*selector.Selector) + l.selector = m + if m.Page() != curPage { + l.nextPage = m.Page() + l.selector.SetPage(curPage) + cmds = append(cmds, + l.updateCommitsCmd, + l.startLoading(), + ) + } + cmds = append(cmds, cmd) + case logViewDiff: + switch key := msg.(type) { + case tea.KeyMsg: + switch key.String() { + case "h", "left": + l.activeView = logViewCommits + l.selectedCommit = nil + } + } + } + case selector.ActiveMsg: + switch sel := msg.IdentifiableItem.(type) { + case LogItem: + l.activeCommit = sel.Commit + } + cmds = append(cmds, updateStatusBarCmd) + case selector.SelectMsg: + switch sel := msg.IdentifiableItem.(type) { + case LogItem: + cmds = append(cmds, + l.selectCommitCmd(sel.Commit), + l.startLoading(), + ) + } + case LogCommitMsg: + l.selectedCommit = msg + cmds = append(cmds, l.loadDiffCmd) + case LogDiffMsg: + l.currentDiff = msg + l.vp.SetContent( + lipgloss.JoinVertical(lipgloss.Top, + l.renderCommit(l.selectedCommit), + l.renderSummary(msg), + l.renderDiff(msg), + ), + ) + l.vp.GotoTop() + l.activeView = logViewDiff + cmds = append(cmds, + updateStatusBarCmd, + // stop loading after setting the viewport content + l.stopLoading(), + ) + case tea.WindowSizeMsg: + if l.selectedCommit != nil && l.currentDiff != nil { + l.vp.SetContent( + lipgloss.JoinVertical(lipgloss.Top, + l.renderCommit(l.selectedCommit), + l.renderSummary(l.currentDiff), + l.renderDiff(l.currentDiff), + ), + ) + } + if l.repo != nil { + cmds = append(cmds, + l.updateCommitsCmd, + // start loading on resize since the number of commits per page + // might change and we'd need to load more commits. + l.startLoading(), + ) + } + } + if l.loading { + s, cmd := l.spinner.Update(msg) + if cmd != nil { + cmds = append(cmds, cmd) + } + l.spinner = s + } + switch l.activeView { + case logViewDiff: + vp, cmd := l.vp.Update(msg) + l.vp = vp.(*viewport.Viewport) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return l, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (l *Log) View() string { + if l.loading && l.loadingTime.Add(waitBeforeLoading).Before(time.Now()) { + msg := fmt.Sprintf("%s loading commit", l.spinner.View()) + if l.selectedCommit == nil { + msg += "s" + } + msg += "…" + return msg + } + switch l.activeView { + case logViewCommits: + return l.selector.View() + case logViewDiff: + return l.vp.View() + default: + return "" + } +} + +// StatusBarValue returns the status bar value. +func (l *Log) StatusBarValue() string { + if l.loading { + return "" + } + c := l.activeCommit + if c == nil { + return "" + } + who := c.Author.Name + if email := c.Author.Email; email != "" { + who += " <" + email + ">" + } + value := c.ID.String() + if who != "" { + value += " by " + who + } + return value +} + +// StatusBarInfo returns the status bar info. +func (l *Log) StatusBarInfo() string { + switch l.activeView { + case logViewCommits: + // We're using l.nextPage instead of l.selector.Paginator.Page because + // of the paginator hack above. + return fmt.Sprintf("p. %d/%d", l.nextPage+1, l.selector.TotalPages()) + case logViewDiff: + return fmt.Sprintf("☰ %.f%%", l.vp.ScrollPercent()*100) + default: + return "" + } +} + +func (l *Log) countCommitsCmd() tea.Msg { + if l.ref == nil { + return common.ErrorMsg(errNoRef) + } + count, err := l.repo.CountCommits(l.ref) + if err != nil { + return common.ErrorMsg(err) + } + return LogCountMsg(count) +} + +func (l *Log) updateCommitsCmd() tea.Msg { + count := l.count + if l.count == 0 { + switch msg := l.countCommitsCmd().(type) { + case common.ErrorMsg: + return msg + case LogCountMsg: + count = int64(msg) + } + } + if l.ref == nil { + return common.ErrorMsg(errNoRef) + } + items := make([]selector.IdentifiableItem, count) + page := l.nextPage + limit := l.selector.PerPage() + skip := page * limit + // CommitsByPage pages start at 1 + cc, err := l.repo.CommitsByPage(l.ref, page+1, limit) + if err != nil { + return common.ErrorMsg(err) + } + for i, c := range cc { + idx := i + skip + if int64(idx) >= count { + break + } + items[idx] = LogItem{Commit: c} + } + return LogItemsMsg(items) +} + +func (l *Log) selectCommitCmd(commit *ggit.Commit) tea.Cmd { + return func() tea.Msg { + return LogCommitMsg(commit) + } +} + +func (l *Log) loadDiffCmd() tea.Msg { + diff, err := l.repo.Diff(l.selectedCommit) + if err != nil { + return common.ErrorMsg(err) + } + return LogDiffMsg(diff) +} + +func renderCtx() gansi.RenderContext { + return gansi.NewRenderContext(gansi.Options{ + ColorProfile: termenv.TrueColor, + Styles: common.StyleConfig(), + }) +} + +func (l *Log) renderCommit(c *ggit.Commit) string { + s := strings.Builder{} + // FIXME: lipgloss prints empty lines when CRLF is used + // sanitize commit message from CRLF + msg := strings.ReplaceAll(c.Message, "\r\n", "\n") + s.WriteString(fmt.Sprintf("%s\n%s\n%s\n%s\n", + l.common.Styles.LogCommitHash.Render("commit "+c.ID.String()), + l.common.Styles.LogCommitAuthor.Render(fmt.Sprintf("Author: %s <%s>", c.Author.Name, c.Author.Email)), + l.common.Styles.LogCommitDate.Render("Date: "+c.Committer.When.Format(time.UnixDate)), + l.common.Styles.LogCommitBody.Render(msg), + )) + return wrap.String(s.String(), l.common.Width-2) +} + +func (l *Log) renderSummary(diff *ggit.Diff) string { + stats := strings.Split(diff.Stats().String(), "\n") + for i, line := range stats { + ch := strings.Split(line, "|") + if len(ch) > 1 { + adddel := ch[len(ch)-1] + adddel = strings.ReplaceAll(adddel, "+", l.common.Styles.LogCommitStatsAdd.Render("+")) + adddel = strings.ReplaceAll(adddel, "-", l.common.Styles.LogCommitStatsDel.Render("-")) + stats[i] = strings.Join(ch[:len(ch)-1], "|") + "|" + adddel + } + } + return wrap.String(strings.Join(stats, "\n"), l.common.Width-2) +} + +func (l *Log) renderDiff(diff *ggit.Diff) string { + var s strings.Builder + var pr strings.Builder + diffChroma := &gansi.CodeBlockElement{ + Code: diff.Patch(), + Language: "diff", + } + err := diffChroma.Render(&pr, renderCtx()) + if err != nil { + s.WriteString(fmt.Sprintf("\n%s", err.Error())) + } else { + s.WriteString(fmt.Sprintf("\n%s", pr.String())) + } + return wrap.String(s.String(), l.common.Width) +} diff --git a/ui/pages/repo/logitem.go b/ui/pages/repo/logitem.go new file mode 100644 index 000000000..f4c3fc6a4 --- /dev/null +++ b/ui/pages/repo/logitem.go @@ -0,0 +1,155 @@ +package repo + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/muesli/reflow/truncate" +) + +// LogItem is a item in the log list that displays a git commit. +type LogItem struct { + *git.Commit + copied time.Time +} + +// ID implements selector.IdentifiableItem. +func (i LogItem) ID() string { + return i.Hash() +} + +func (i LogItem) Hash() string { + return i.Commit.ID.String() +} + +// Title returns the item title. Implements list.DefaultItem. +func (i LogItem) Title() string { + if i.Commit != nil { + return strings.Split(i.Commit.Message, "\n")[0] + } + return "" +} + +// Description returns the item description. Implements list.DefaultItem. +func (i LogItem) Description() string { return "" } + +// FilterValue implements list.Item. +func (i LogItem) FilterValue() string { return i.Title() } + +// LogItemDelegate is the delegate for LogItem. +type LogItemDelegate struct { + common *common.Common +} + +// Height returns the item height. Implements list.ItemDelegate. +func (d LogItemDelegate) Height() int { return 2 } + +// Spacing returns the item spacing. Implements list.ItemDelegate. +func (d LogItemDelegate) Spacing() int { return 1 } + +// Update updates the item. Implements list.ItemDelegate. +func (d LogItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + idx := m.Index() + item, ok := m.SelectedItem().(LogItem) + if !ok { + return nil + } + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, d.common.KeyMap.Copy): + item.copied = time.Now() + d.common.Copy.Copy(item.Hash()) + return m.SetItem(idx, item) + } + } + return nil +} + +var ( + faint = func(s string) string { return lipgloss.NewStyle().Faint(true).Render(s) } +) + +// Render renders the item. Implements list.ItemDelegate. +func (d LogItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + styles := d.common.Styles + i, ok := listItem.(LogItem) + if !ok { + return + } + if i.Commit == nil { + return + } + + titleStyle := styles.LogItemTitle.Copy() + style := styles.LogItemInactive + if index == m.Index() { + titleStyle.Bold(true) + style = styles.LogItemActive + } + hash := i.Commit.ID.String()[:7] + if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) { + hash = "copied" + } + title := titleStyle.Render( + common.TruncateString(i.Title(), + m.Width()- + style.GetHorizontalFrameSize()- + // 9 is the length of the hash (7) + the left padding (1) + the + // title truncation symbol (1) + 9), + ) + hashStyle := styles.LogItemHash.Copy(). + Align(lipgloss.Right). + PaddingLeft(1). + Width(m.Width() - + style.GetHorizontalFrameSize() - + lipgloss.Width(title) - 1) // 1 is for the left padding + if index == m.Index() { + hashStyle = hashStyle.Bold(true) + } + hash = hashStyle.Render(hash) + if m.Width()-style.GetHorizontalFrameSize()-hashStyle.GetHorizontalFrameSize()-hashStyle.GetWidth() <= 0 { + hash = "" + title = titleStyle.Render( + common.TruncateString(i.Title(), + m.Width()-style.GetHorizontalFrameSize()), + ) + } + author := i.Author.Name + commiter := i.Committer.Name + who := "" + if author != "" && commiter != "" { + who = commiter + faint(" committed") + if author != commiter { + who = author + faint(" authored and ") + who + } + who += " " + } + date := fmt.Sprintf("on %s", i.Committer.When.Format("Feb 02")) + date = faint(date) + if i.Committer.When.Year() != time.Now().Year() { + date += fmt.Sprintf(" %d", i.Committer.When.Year()) + } + who += date + who = common.TruncateString(who, m.Width()-style.GetHorizontalFrameSize()) + fmt.Fprint(w, + style.Render( + lipgloss.JoinVertical(lipgloss.Top, + truncate.String(fmt.Sprintf("%s%s", + title, + hash, + ), uint(m.Width()-style.GetHorizontalFrameSize())), + who, + ), + ), + ) +} diff --git a/ui/pages/repo/readme.go b/ui/pages/repo/readme.go new file mode 100644 index 000000000..8605d3205 --- /dev/null +++ b/ui/pages/repo/readme.go @@ -0,0 +1,114 @@ +package repo + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/code" + "github.com/charmbracelet/soft-serve/ui/git" +) + +type ReadmeMsg struct{} + +// Readme is the readme component page. +type Readme struct { + common common.Common + code *code.Code + ref RefMsg + repo git.GitRepo +} + +// NewReadme creates a new readme model. +func NewReadme(common common.Common) *Readme { + readme := code.New(common, "", "") + readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.") + return &Readme{ + code: readme, + common: common, + } +} + +// SetSize implements common.Component. +func (r *Readme) SetSize(width, height int) { + r.common.SetSize(width, height) + r.code.SetSize(width, height) +} + +// ShortHelp implements help.KeyMap. +func (r *Readme) ShortHelp() []key.Binding { + b := []key.Binding{ + r.common.KeyMap.UpDown, + } + return b +} + +// FullHelp implements help.KeyMap. +func (r *Readme) FullHelp() [][]key.Binding { + k := r.code.KeyMap + b := [][]key.Binding{ + { + k.PageDown, + k.PageUp, + k.HalfPageDown, + k.HalfPageUp, + }, + { + k.Down, + k.Up, + }, + } + return b +} + +// Init implements tea.Model. +func (r *Readme) Init() tea.Cmd { + if r.repo == nil { + return common.ErrorCmd(git.ErrMissingRepo) + } + rm, rp := r.repo.Readme() + r.code.GotoTop() + return tea.Batch( + r.code.SetContent(rm, rp), + r.updateReadmeCmd, + ) +} + +// Update implements tea.Model. +func (r *Readme) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case RepoMsg: + r.repo = git.GitRepo(msg) + cmds = append(cmds, r.Init()) + case RefMsg: + r.ref = msg + cmds = append(cmds, r.Init()) + } + c, cmd := r.code.Update(msg) + r.code = c.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + return r, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (r *Readme) View() string { + return r.code.View() +} + +// StatusBarValue implements statusbar.StatusBar. +func (r *Readme) StatusBarValue() string { + return "" +} + +// StatusBarInfo implements statusbar.StatusBar. +func (r *Readme) StatusBarInfo() string { + return fmt.Sprintf("☰ %.f%%", r.code.ScrollPercent()*100) +} + +func (r *Readme) updateReadmeCmd() tea.Msg { + return ReadmeMsg{} +} diff --git a/ui/pages/repo/refs.go b/ui/pages/repo/refs.go new file mode 100644 index 000000000..b5b6c7fed --- /dev/null +++ b/ui/pages/repo/refs.go @@ -0,0 +1,196 @@ +package repo + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/components/tabs" + "github.com/charmbracelet/soft-serve/ui/git" +) + +var ( + errNoRef = errors.New("no reference specified") +) + +// RefItemsMsg is a message that contains a list of RefItem. +type RefItemsMsg struct { + prefix string + items []selector.IdentifiableItem +} + +// Refs is a component that displays a list of references. +type Refs struct { + common common.Common + selector *selector.Selector + repo git.GitRepo + ref *ggit.Reference + activeRef *ggit.Reference + refPrefix string +} + +// NewRefs creates a new Refs component. +func NewRefs(common common.Common, refPrefix string) *Refs { + r := &Refs{ + common: common, + refPrefix: refPrefix, + } + s := selector.New(common, []selector.IdentifiableItem{}, RefItemDelegate{&common}) + s.SetShowFilter(false) + s.SetShowHelp(false) + s.SetShowPagination(false) + s.SetShowStatusBar(false) + s.SetShowTitle(false) + s.SetFilteringEnabled(false) + s.DisableQuitKeybindings() + r.selector = s + return r +} + +// SetSize implements common.Component. +func (r *Refs) SetSize(width, height int) { + r.common.SetSize(width, height) + r.selector.SetSize(width, height) +} + +// ShortHelp implements help.KeyMap. +func (r *Refs) ShortHelp() []key.Binding { + copyKey := r.common.KeyMap.Copy + copyKey.SetHelp("c", "copy ref") + k := r.selector.KeyMap + return []key.Binding{ + r.common.KeyMap.SelectItem, + k.CursorUp, + k.CursorDown, + copyKey, + } +} + +// FullHelp implements help.KeyMap. +func (r *Refs) FullHelp() [][]key.Binding { + copyKey := r.common.KeyMap.Copy + copyKey.SetHelp("c", "copy ref") + k := r.selector.KeyMap + return [][]key.Binding{ + {r.common.KeyMap.SelectItem}, + { + k.CursorUp, + k.CursorDown, + k.NextPage, + k.PrevPage, + }, + { + k.GoToStart, + k.GoToEnd, + copyKey, + }, + } +} + +// Init implements tea.Model. +func (r *Refs) Init() tea.Cmd { + return r.updateItemsCmd +} + +// Update implements tea.Model. +func (r *Refs) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case RepoMsg: + r.selector.Select(0) + r.repo = git.GitRepo(msg) + cmds = append(cmds, r.Init()) + case RefMsg: + r.ref = msg + cmds = append(cmds, r.Init()) + case RefItemsMsg: + if r.refPrefix == msg.prefix { + cmds = append(cmds, r.selector.SetItems(msg.items)) + i := r.selector.SelectedItem() + if i != nil { + r.activeRef = i.(RefItem).Reference + } + } + case selector.ActiveMsg: + switch sel := msg.IdentifiableItem.(type) { + case RefItem: + r.activeRef = sel.Reference + } + cmds = append(cmds, updateStatusBarCmd) + case selector.SelectMsg: + switch i := msg.IdentifiableItem.(type) { + case RefItem: + cmds = append(cmds, + switchRefCmd(i.Reference), + tabs.SelectTabCmd(int(filesTab)), + ) + } + case tea.KeyMsg: + switch msg.String() { + case "l", "right": + cmds = append(cmds, r.selector.SelectItem) + } + } + m, cmd := r.selector.Update(msg) + r.selector = m.(*selector.Selector) + if cmd != nil { + cmds = append(cmds, cmd) + } + return r, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (r *Refs) View() string { + return r.selector.View() +} + +// StatusBarValue implements statusbar.StatusBar. +func (r *Refs) StatusBarValue() string { + if r.activeRef == nil { + return "" + } + return r.activeRef.Name().String() +} + +// StatusBarInfo implements statusbar.StatusBar. +func (r *Refs) StatusBarInfo() string { + totalPages := r.selector.TotalPages() + if totalPages > 1 { + return fmt.Sprintf("p. %d/%d", r.selector.Page()+1, totalPages) + } + return "" +} + +func (r *Refs) updateItemsCmd() tea.Msg { + its := make(RefItems, 0) + refs, err := r.repo.References() + if err != nil { + return common.ErrorMsg(err) + } + for _, ref := range refs { + if strings.HasPrefix(ref.Name().String(), r.refPrefix) { + its = append(its, RefItem{Reference: ref}) + } + } + sort.Sort(its) + items := make([]selector.IdentifiableItem, len(its)) + for i, it := range its { + items[i] = it + } + return RefItemsMsg{ + items: items, + prefix: r.refPrefix, + } +} + +func switchRefCmd(ref *ggit.Reference) tea.Cmd { + return func() tea.Msg { + return RefMsg(ref) + } +} diff --git a/ui/pages/repo/refsitem.go b/ui/pages/repo/refsitem.go new file mode 100644 index 000000000..d34f9d13a --- /dev/null +++ b/ui/pages/repo/refsitem.go @@ -0,0 +1,111 @@ +package repo + +import ( + "fmt" + "io" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" +) + +// RefItem is a git reference item. +type RefItem struct { + *git.Reference +} + +// ID implements selector.IdentifiableItem. +func (i RefItem) ID() string { + return i.Reference.Name().String() +} + +// Title implements list.DefaultItem. +func (i RefItem) Title() string { + return i.Reference.Name().Short() +} + +// Description implements list.DefaultItem. +func (i RefItem) Description() string { + return "" +} + +// Short returns the short name of the reference. +func (i RefItem) Short() string { + return i.Reference.Name().Short() +} + +// FilterValue implements list.Item. +func (i RefItem) FilterValue() string { return i.Short() } + +// RefItems is a list of git references. +type RefItems []RefItem + +// Len implements sort.Interface. +func (cl RefItems) Len() int { return len(cl) } + +// Swap implements sort.Interface. +func (cl RefItems) Swap(i, j int) { cl[i], cl[j] = cl[j], cl[i] } + +// Less implements sort.Interface. +func (cl RefItems) Less(i, j int) bool { + return cl[i].Short() < cl[j].Short() +} + +// RefItemDelegate is the delegate for the ref item. +type RefItemDelegate struct { + common *common.Common +} + +// Height implements list.ItemDelegate. +func (d RefItemDelegate) Height() int { return 1 } + +// Spacing implements list.ItemDelegate. +func (d RefItemDelegate) Spacing() int { return 0 } + +// Update implements list.ItemDelegate. +func (d RefItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + idx := m.Index() + item, ok := m.SelectedItem().(RefItem) + if !ok { + return nil + } + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, d.common.KeyMap.Copy): + d.common.Copy.Copy(item.Title()) + return m.SetItem(idx, item) + } + } + return nil +} + +// Render implements list.ItemDelegate. +func (d RefItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + s := d.common.Styles + i, ok := listItem.(RefItem) + if !ok { + return + } + + ref := i.Short() + ref = s.RefItemBranch.Render(ref) + if i.Reference.IsTag() { + ref = s.RefItemTag.Render(ref) + } + refMaxWidth := m.Width() - + s.RefItemSelector.GetMarginLeft() - + s.RefItemSelector.GetWidth() - + s.RefItemInactive.GetMarginLeft() + ref = common.TruncateString(ref, refMaxWidth) + refStyle := s.RefItemInactive + selector := s.RefItemSelector.Render(" ") + if index == m.Index() { + selector = s.RefItemSelector.Render(">") + refStyle = s.RefItemActive + } + ref = refStyle.Render(ref) + fmt.Fprint(w, selector, ref) +} diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go new file mode 100644 index 000000000..f448ab697 --- /dev/null +++ b/ui/pages/repo/repo.go @@ -0,0 +1,345 @@ +package repo + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/config" + ggit "github.com/charmbracelet/soft-serve/git" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/statusbar" + "github.com/charmbracelet/soft-serve/ui/components/tabs" + "github.com/charmbracelet/soft-serve/ui/git" +) + +type state int + +const ( + loadingState state = iota + loadedState +) + +type tab int + +const ( + readmeTab tab = iota + filesTab + commitsTab + branchesTab + tagsTab + lastTab +) + +func (t tab) String() string { + return []string{ + "Readme", + "Files", + "Commits", + "Branches", + "Tags", + }[t] +} + +// UpdateStatusBarMsg updates the status bar. +type UpdateStatusBarMsg struct{} + +// RepoMsg is a message that contains a git.Repository. +type RepoMsg git.GitRepo + +// RefMsg is a message that contains a git.Reference. +type RefMsg *ggit.Reference + +// Repo is a view for a git repository. +type Repo struct { + common common.Common + cfg *config.Config + selectedRepo git.GitRepo + activeTab tab + tabs *tabs.Tabs + statusbar *statusbar.StatusBar + boxes []common.Component + ref *ggit.Reference +} + +// New returns a new Repo. +func New(cfg *config.Config, c common.Common) *Repo { + sb := statusbar.New(c) + ts := make([]string, lastTab) + // Tabs must match the order of tab constants above. + for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} { + ts[i] = t.String() + } + tb := tabs.New(c, ts) + readme := NewReadme(c) + log := NewLog(c) + files := NewFiles(c) + branches := NewRefs(c, ggit.RefsHeads) + tags := NewRefs(c, ggit.RefsTags) + // Make sure the order matches the order of tab constants above. + boxes := []common.Component{ + readme, + files, + log, + branches, + tags, + } + r := &Repo{ + cfg: cfg, + common: c, + tabs: tb, + statusbar: sb, + boxes: boxes, + } + return r +} + +// SetSize implements common.Component. +func (r *Repo) SetSize(width, height int) { + r.common.SetSize(width, height) + hm := r.common.Styles.RepoBody.GetVerticalFrameSize() + + r.common.Styles.RepoHeader.GetHeight() + + r.common.Styles.RepoHeader.GetVerticalFrameSize() + + r.common.Styles.StatusBar.GetHeight() + + r.common.Styles.Tabs.GetHeight() + + r.common.Styles.Tabs.GetVerticalFrameSize() + r.tabs.SetSize(width, height-hm) + r.statusbar.SetSize(width, height-hm) + for _, b := range r.boxes { + b.SetSize(width, height-hm) + } +} + +func (r *Repo) commonHelp() []key.Binding { + b := make([]key.Binding, 0) + back := r.common.KeyMap.Back + back.SetHelp("esc", "back to menu") + tab := r.common.KeyMap.Section + tab.SetHelp("tab", "switch tab") + b = append(b, back) + b = append(b, tab) + return b +} + +// ShortHelp implements help.KeyMap. +func (r *Repo) ShortHelp() []key.Binding { + b := r.commonHelp() + b = append(b, r.boxes[r.activeTab].(help.KeyMap).ShortHelp()...) + return b +} + +// FullHelp implements help.KeyMap. +func (r *Repo) FullHelp() [][]key.Binding { + b := make([][]key.Binding, 0) + b = append(b, r.commonHelp()) + b = append(b, r.boxes[r.activeTab].(help.KeyMap).FullHelp()...) + return b +} + +// Init implements tea.View. +func (r *Repo) Init() tea.Cmd { + return tea.Batch( + r.tabs.Init(), + r.statusbar.Init(), + ) +} + +// Update implements tea.Model. +func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case RepoMsg: + r.activeTab = 0 + r.selectedRepo = git.GitRepo(msg) + cmds = append(cmds, + r.tabs.Init(), + r.updateRefCmd, + r.updateModels(msg), + ) + case RefMsg: + r.ref = msg + for _, b := range r.boxes { + cmds = append(cmds, b.Init()) + } + cmds = append(cmds, + r.updateStatusBarCmd, + r.updateModels(msg), + ) + case tabs.SelectTabMsg: + r.activeTab = tab(msg) + t, cmd := r.tabs.Update(msg) + r.tabs = t.(*tabs.Tabs) + if cmd != nil { + cmds = append(cmds, cmd) + } + case tabs.ActiveTabMsg: + r.activeTab = tab(msg) + if r.selectedRepo != nil { + cmds = append(cmds, + r.updateStatusBarCmd, + ) + } + case tea.KeyMsg, tea.MouseMsg: + t, cmd := r.tabs.Update(msg) + r.tabs = t.(*tabs.Tabs) + if cmd != nil { + cmds = append(cmds, cmd) + } + if r.selectedRepo != nil { + cmds = append(cmds, r.updateStatusBarCmd) + } + case ReadmeMsg: + case FileItemsMsg: + f, cmd := r.boxes[filesTab].Update(msg) + r.boxes[filesTab] = f.(*Files) + if cmd != nil { + cmds = append(cmds, cmd) + } + // The Log bubble is the only bubble that uses a spinner, so this is fine + // for now. We need to pass the TickMsg to the Log bubble when the Log is + // loading but not the current selected tab so that the spinner works. + case LogCountMsg, LogItemsMsg, spinner.TickMsg: + l, cmd := r.boxes[commitsTab].Update(msg) + r.boxes[commitsTab] = l.(*Log) + if cmd != nil { + cmds = append(cmds, cmd) + } + case RefItemsMsg: + switch msg.prefix { + case ggit.RefsHeads: + b, cmd := r.boxes[branchesTab].Update(msg) + r.boxes[branchesTab] = b.(*Refs) + if cmd != nil { + cmds = append(cmds, cmd) + } + case ggit.RefsTags: + t, cmd := r.boxes[tagsTab].Update(msg) + r.boxes[tagsTab] = t.(*Refs) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + case UpdateStatusBarMsg: + cmds = append(cmds, r.updateStatusBarCmd) + case tea.WindowSizeMsg: + cmds = append(cmds, r.updateModels(msg)) + } + s, cmd := r.statusbar.Update(msg) + r.statusbar = s.(*statusbar.StatusBar) + if cmd != nil { + cmds = append(cmds, cmd) + } + m, cmd := r.boxes[r.activeTab].Update(msg) + r.boxes[r.activeTab] = m.(common.Component) + if cmd != nil { + cmds = append(cmds, cmd) + } + return r, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (r *Repo) View() string { + s := r.common.Styles.Repo.Copy(). + Width(r.common.Width). + Height(r.common.Height) + repoBodyStyle := r.common.Styles.RepoBody.Copy() + hm := repoBodyStyle.GetVerticalFrameSize() + + r.common.Styles.RepoHeader.GetHeight() + + r.common.Styles.RepoHeader.GetVerticalFrameSize() + + r.common.Styles.StatusBar.GetHeight() + + r.common.Styles.Tabs.GetHeight() + + r.common.Styles.Tabs.GetVerticalFrameSize() + mainStyle := repoBodyStyle. + Height(r.common.Height - hm) + main := r.boxes[r.activeTab].View() + view := lipgloss.JoinVertical(lipgloss.Top, + r.headerView(), + r.tabs.View(), + mainStyle.Render(main), + r.statusbar.View(), + ) + return s.Render(view) +} + +func (r *Repo) headerView() string { + if r.selectedRepo == nil { + return "" + } + cfg := r.cfg + truncate := lipgloss.NewStyle().MaxWidth(r.common.Width) + name := r.common.Styles.RepoHeaderName.Render(r.selectedRepo.Name()) + desc := r.selectedRepo.Description() + if desc == "" { + desc = name + name = "" + } else { + desc = r.common.Styles.RepoHeaderDesc.Render(desc) + } + // TODO move this into a style. + urlStyle := lipgloss.NewStyle(). + MarginLeft(1). + Foreground(lipgloss.Color("168")). + Width(r.common.Width - lipgloss.Width(desc) - 1). + Align(lipgloss.Right) + url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo()) + url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1) + url = urlStyle.Render(url) + style := r.common.Styles.RepoHeader.Copy().Width(r.common.Width) + return style.Render( + lipgloss.JoinVertical(lipgloss.Top, + truncate.Render(name), + truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left, + desc, + url, + )), + ), + ) +} + +func (r *Repo) updateStatusBarCmd() tea.Msg { + if r.selectedRepo == nil { + return nil + } + value := r.boxes[r.activeTab].(statusbar.Model).StatusBarValue() + info := r.boxes[r.activeTab].(statusbar.Model).StatusBarInfo() + ref := "" + if r.ref != nil { + ref = r.ref.Name().Short() + } + return statusbar.StatusBarMsg{ + Key: r.selectedRepo.Repo(), + Value: value, + Info: info, + Branch: fmt.Sprintf("* %s", ref), + } +} + +func (r *Repo) updateRefCmd() tea.Msg { + if r.selectedRepo == nil { + return nil + } + head, err := r.selectedRepo.HEAD() + if err != nil { + return common.ErrorMsg(err) + } + return RefMsg(head) +} + +func (r *Repo) updateModels(msg tea.Msg) tea.Cmd { + cmds := make([]tea.Cmd, 0) + for i, b := range r.boxes { + m, cmd := b.Update(msg) + r.boxes[i] = m.(common.Component) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return tea.Batch(cmds...) +} + +func updateStatusBarCmd() tea.Msg { + return UpdateStatusBarMsg{} +} diff --git a/ui/pages/selection/item.go b/ui/pages/selection/item.go new file mode 100644 index 000000000..6e0c7efc8 --- /dev/null +++ b/ui/pages/selection/item.go @@ -0,0 +1,170 @@ +package selection + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/git" + "github.com/dustin/go-humanize" +) + +// Item represents a single item in the selector. +type Item struct { + repo git.GitRepo + lastUpdate time.Time + cmd string + copied time.Time +} + +// ID implements selector.IdentifiableItem. +func (i Item) ID() string { + return i.repo.Repo() +} + +// Title returns the item title. Implements list.DefaultItem. +func (i Item) Title() string { return i.repo.Name() } + +// Description returns the item description. Implements list.DefaultItem. +func (i Item) Description() string { return i.repo.Description() } + +// FilterValue implements list.Item. +func (i Item) FilterValue() string { return i.Title() } + +// Command returns the item Command view. +func (i Item) Command() string { + return i.cmd +} + +// ItemDelegate is the delegate for the item. +type ItemDelegate struct { + common *common.Common + activeBox *box +} + +// Width returns the item width. +func (d ItemDelegate) Width() int { + width := d.common.Styles.MenuItem.GetHorizontalFrameSize() + d.common.Styles.MenuItem.GetWidth() + return width +} + +// Height returns the item height. Implements list.ItemDelegate. +func (d ItemDelegate) Height() int { + height := d.common.Styles.MenuItem.GetVerticalFrameSize() + d.common.Styles.MenuItem.GetHeight() + return height +} + +// Spacing returns the spacing between items. Implements list.ItemDelegate. +func (d ItemDelegate) Spacing() int { return 1 } + +// Update implements list.ItemDelegate. +func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { + idx := m.Index() + item, ok := m.SelectedItem().(Item) + if !ok { + return nil + } + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, d.common.KeyMap.Copy): + item.copied = time.Now() + d.common.Copy.Copy(item.Command()) + return m.SetItem(idx, item) + } + } + return nil +} + +// Render implements list.ItemDelegate. +func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + styles := d.common.Styles + i := listItem.(Item) + s := strings.Builder{} + var matchedRunes []int + + // Conditions + var ( + isSelected = index == m.Index() + isFiltered = m.FilterState() == list.Filtering || m.FilterState() == list.FilterApplied + ) + + itemStyle := styles.MenuItem.Copy() + if isSelected { + itemStyle = itemStyle.Copy(). + BorderStyle(lipgloss.Border{ + Left: "┃", + }). + BorderForeground(styles.ActiveBorderColor) + if d.activeBox != nil && *d.activeBox == readmeBox { + itemStyle = itemStyle.BorderForeground(styles.InactiveBorderColor) + } + } + + title := i.Title() + title = common.TruncateString(title, m.Width()-itemStyle.GetHorizontalFrameSize()) + if i.repo.IsPrivate() { + title += " 🔒" + } + if isSelected { + title += " " + } + updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.lastUpdate)) + if m.Width()-itemStyle.GetHorizontalFrameSize()-lipgloss.Width(updatedStr)-lipgloss.Width(title) <= 0 { + updatedStr = "" + } + updatedStyle := styles.MenuLastUpdate.Copy(). + Align(lipgloss.Right). + Width(m.Width() - itemStyle.GetHorizontalFrameSize() - lipgloss.Width(title)) + if isSelected { + updatedStyle = updatedStyle.Bold(true) + } + updated := updatedStyle.Render(updatedStr) + + if isFiltered && index < len(m.VisibleItems()) { + // Get indices of matched characters + matchedRunes = m.MatchesForItem(index) + } + + if isFiltered { + unmatched := lipgloss.NewStyle().Inline(true) + matched := unmatched.Copy().Underline(true) + if isSelected { + unmatched = unmatched.Bold(true) + matched = matched.Bold(true) + } + title = lipgloss.StyleRunes(title, matchedRunes, matched, unmatched) + } + titleStyle := lipgloss.NewStyle() + if isSelected { + titleStyle = titleStyle.Bold(true) + } + title = titleStyle.Render(title) + desc := i.Description() + descStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("243")) + if desc == "" { + desc = "No description" + descStyle = descStyle.Faint(true) + } + desc = common.TruncateString(desc, m.Width()-itemStyle.GetHorizontalFrameSize()) + desc = descStyle.Render(desc) + + s.WriteString(lipgloss.JoinHorizontal(lipgloss.Bottom, title, updated)) + s.WriteString("\n") + s.WriteString(desc) + s.WriteString("\n") + cmdStyle := styles.RepoCommand.Copy() + cmd := common.TruncateString(i.Command(), m.Width()-itemStyle.GetHorizontalFrameSize()) + cmd = cmdStyle.Render(cmd) + if !i.copied.IsZero() && i.copied.Add(time.Second).After(time.Now()) { + cmd = cmdStyle.Render("Copied!") + } + s.WriteString(cmd) + fmt.Fprint(w, itemStyle.Render(s.String())) +} diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go new file mode 100644 index 000000000..4fe2fdd10 --- /dev/null +++ b/ui/pages/selection/selection.go @@ -0,0 +1,321 @@ +package selection + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/code" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/components/tabs" + "github.com/charmbracelet/soft-serve/ui/git" + wgit "github.com/charmbracelet/wish/git" + "github.com/gliderlabs/ssh" +) + +type box int + +const ( + selectorBox box = iota + readmeBox + lastBox +) + +func (b box) String() string { + return []string{ + "Repositories", + "About", + }[b] +} + +// Selection is the model for the selection screen/page. +type Selection struct { + cfg *config.Config + pk ssh.PublicKey + common common.Common + readme *code.Code + readmeHeight int + selector *selector.Selector + activeBox box + tabs *tabs.Tabs +} + +// New creates a new selection model. +func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection { + ts := make([]string, lastBox) + for i, b := range []box{selectorBox, readmeBox} { + ts[i] = b.String() + } + t := tabs.New(common, ts) + t.TabSeparator = lipgloss.NewStyle() + t.TabInactive = lipgloss.NewStyle(). + Bold(true). + UnsetBackground(). + Foreground(common.Styles.InactiveBorderColor). + Padding(0, 1) + t.TabActive = t.TabInactive.Copy(). + Background(lipgloss.Color("62")). + Foreground(lipgloss.Color("230")) + sel := &Selection{ + cfg: cfg, + pk: pk, + common: common, + activeBox: selectorBox, // start with the selector focused + tabs: t, + } + readme := code.New(common, "", "") + readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.") + selector := selector.New(common, + []selector.IdentifiableItem{}, + ItemDelegate{&common, &sel.activeBox}) + selector.SetShowTitle(false) + selector.SetShowHelp(false) + selector.SetShowStatusBar(false) + selector.DisableQuitKeybindings() + sel.selector = selector + sel.readme = readme + return sel +} + +func (s *Selection) getMargins() (wm, hm int) { + wm = 0 + hm = s.common.Styles.Tabs.GetVerticalFrameSize() + + s.common.Styles.Tabs.GetHeight() + + 2 // tabs margin see View() + switch s.activeBox { + case selectorBox: + hm += s.common.Styles.SelectorBox.GetVerticalFrameSize() + + s.common.Styles.SelectorBox.GetHeight() + case readmeBox: + hm += s.common.Styles.ReadmeBox.GetVerticalFrameSize() + + s.common.Styles.ReadmeBox.GetHeight() + + 1 // readme statusbar + } + return +} + +// SetSize implements common.Component. +func (s *Selection) SetSize(width, height int) { + s.common.SetSize(width, height) + wm, hm := s.getMargins() + s.tabs.SetSize(width, height-hm) + s.selector.SetSize(width-wm, height-hm) + s.readme.SetSize(width-wm, height-hm) +} + +// ShortHelp implements help.KeyMap. +func (s *Selection) ShortHelp() []key.Binding { + k := s.selector.KeyMap + kb := make([]key.Binding, 0) + kb = append(kb, + s.common.KeyMap.UpDown, + s.common.KeyMap.Section, + ) + if s.activeBox == selectorBox { + copyKey := s.common.KeyMap.Copy + copyKey.SetHelp("c", "copy command") + kb = append(kb, + s.common.KeyMap.Select, + k.Filter, + k.ClearFilter, + copyKey, + ) + } + return kb +} + +// FullHelp implements help.KeyMap. +func (s *Selection) FullHelp() [][]key.Binding { + switch s.activeBox { + case readmeBox: + k := s.readme.KeyMap + return [][]key.Binding{ + { + k.PageDown, + k.PageUp, + }, + { + k.HalfPageDown, + k.HalfPageUp, + }, + { + k.Down, + k.Up, + }, + } + case selectorBox: + copyKey := s.common.KeyMap.Copy + copyKey.SetHelp("c", "copy command") + k := s.selector.KeyMap + return [][]key.Binding{ + { + s.common.KeyMap.Select, + copyKey, + k.CursorUp, + k.CursorDown, + }, + { + k.NextPage, + k.PrevPage, + k.GoToStart, + k.GoToEnd, + }, + { + k.Filter, + k.ClearFilter, + k.CancelWhileFiltering, + k.AcceptWhileFiltering, + }, + } + } + return [][]key.Binding{} +} + +// Init implements tea.Model. +func (s *Selection) Init() tea.Cmd { + var readmeCmd tea.Cmd + items := make([]selector.IdentifiableItem, 0) + cfg := s.cfg + pk := s.pk + // Put configured repos first + for _, r := range cfg.Repos { + acc := cfg.AuthRepo(r.Repo, pk) + if r.Private && acc < wgit.ReadOnlyAccess { + continue + } + repo, err := cfg.Source.GetRepo(r.Repo) + if err != nil { + continue + } + items = append(items, Item{ + repo: repo, + cmd: git.RepoURL(cfg.Host, cfg.Port, r.Repo), + }) + } + for _, r := range cfg.Source.AllRepos() { + if r.Repo() == "config" { + rm, rp := r.Readme() + s.readmeHeight = strings.Count(rm, "\n") + readmeCmd = s.readme.SetContent(rm, rp) + } + acc := cfg.AuthRepo(r.Repo(), pk) + if r.IsPrivate() && acc < wgit.ReadOnlyAccess { + continue + } + exists := false + lc, err := r.Commit("HEAD") + if err != nil { + return common.ErrorCmd(err) + } + lastUpdate := lc.Committer.When + if lastUpdate.IsZero() { + lastUpdate = lc.Author.When + } + for i, item := range items { + item := item.(Item) + if item.repo.Repo() == r.Repo() { + exists = true + item.lastUpdate = lastUpdate + items[i] = item + break + } + } + if !exists { + items = append(items, Item{ + repo: r, + lastUpdate: lastUpdate, + cmd: git.RepoURL(cfg.Host, cfg.Port, r.Name()), + }) + } + } + return tea.Batch( + s.selector.Init(), + s.selector.SetItems(items), + readmeCmd, + ) +} + +// Update implements tea.Model. +func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + r, cmd := s.readme.Update(msg) + s.readme = r.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + m, cmd := s.selector.Update(msg) + s.selector = m.(*selector.Selector) + if cmd != nil { + cmds = append(cmds, cmd) + } + case tea.KeyMsg, tea.MouseMsg: + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, s.common.KeyMap.Back): + cmds = append(cmds, s.selector.Init()) + } + } + t, cmd := s.tabs.Update(msg) + s.tabs = t.(*tabs.Tabs) + if cmd != nil { + cmds = append(cmds, cmd) + } + case tabs.ActiveTabMsg: + s.activeBox = box(msg) + } + switch s.activeBox { + case readmeBox: + r, cmd := s.readme.Update(msg) + s.readme = r.(*code.Code) + if cmd != nil { + cmds = append(cmds, cmd) + } + case selectorBox: + m, cmd := s.selector.Update(msg) + s.selector = m.(*selector.Selector) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return s, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (s *Selection) View() string { + var view string + wm, hm := s.getMargins() + hm++ // tabs margin + switch s.activeBox { + case selectorBox: + ss := s.common.Styles.SelectorBox.Copy(). + Width(s.common.Width - wm). + Height(s.common.Height - hm) + view = ss.Render(s.selector.View()) + case readmeBox: + rs := s.common.Styles.ReadmeBox.Copy(). + Height(s.common.Height - hm) + status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100) + readmeStatus := lipgloss.NewStyle(). + Align(lipgloss.Right). + Width(s.common.Width - wm). + Foreground(s.common.Styles.InactiveBorderColor). + Render(status) + view = rs.Render(lipgloss.JoinVertical(lipgloss.Top, + s.readme.View(), + readmeStatus, + )) + } + ts := s.common.Styles.Tabs.Copy(). + MarginBottom(1) + return lipgloss.JoinVertical(lipgloss.Top, + ts.Render(s.tabs.View()), + view, + ) +} diff --git a/internal/tui/style/style.go b/ui/styles/styles.go similarity index 57% rename from internal/tui/style/style.go rename to ui/styles/styles.go index 632b91dc3..b668ee155 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 @@ -15,20 +15,28 @@ type Styles struct { App lipgloss.Style Header lipgloss.Style - Menu lipgloss.Style - MenuCursor lipgloss.Style - MenuItem lipgloss.Style - SelectedMenuItem lipgloss.Style + Menu lipgloss.Style + MenuCursor lipgloss.Style + MenuItem lipgloss.Style + MenuLastUpdate lipgloss.Style + + // Selection page styles + SelectorBox lipgloss.Style + ReadmeBox lipgloss.Style RepoTitleBorder lipgloss.Border RepoNoteBorder lipgloss.Border RepoBodyBorder lipgloss.Border - RepoTitle lipgloss.Style - RepoTitleBox lipgloss.Style - RepoNote lipgloss.Style - RepoNoteBox lipgloss.Style - RepoBody lipgloss.Style + Repo lipgloss.Style + RepoTitle lipgloss.Style + RepoTitleBox lipgloss.Style + RepoCommand lipgloss.Style + RepoNoteBox lipgloss.Style + RepoBody lipgloss.Style + RepoHeader lipgloss.Style + RepoHeaderName lipgloss.Style + RepoHeaderDesc lipgloss.Style Footer lipgloss.Style Branch lipgloss.Style @@ -42,10 +50,12 @@ type Styles struct { AboutNoReadme lipgloss.Style + LogItem lipgloss.Style LogItemSelector lipgloss.Style LogItemActive lipgloss.Style LogItemInactive lipgloss.Style LogItemHash lipgloss.Style + LogItemTitle lipgloss.Style LogCommit lipgloss.Style LogCommitHash lipgloss.Style LogCommitAuthor lipgloss.Style @@ -73,21 +83,37 @@ type Styles struct { TreeNoItems lipgloss.Style Spinner lipgloss.Style + + CodeNoContent lipgloss.Style + + StatusBar lipgloss.Style + StatusBarKey lipgloss.Style + StatusBarValue lipgloss.Style + StatusBarInfo lipgloss.Style + StatusBarBranch lipgloss.Style + StatusBarHelp lipgloss.Style + + Tabs lipgloss.Style + TabInactive lipgloss.Style + TabActive lipgloss.Style + TabSeparator lipgloss.Style } -// DefaultStyles returns default styles for the TUI. +// DefaultStyles returns default styles for the UI. func DefaultStyles() *Styles { s := new(Styles) s.ActiveBorderColor = lipgloss.Color("62") - s.InactiveBorderColor = lipgloss.Color("236") + s.InactiveBorderColor = lipgloss.Color("241") s.App = lipgloss.NewStyle(). Margin(1, 2) s.Header = lipgloss.NewStyle(). - Foreground(lipgloss.Color("62")). - Align(lipgloss.Right). + Align(lipgloss.Left). + Height(1). + PaddingLeft(1). + MarginBottom(1). Bold(true) s.Menu = lipgloss.NewStyle(). @@ -102,11 +128,19 @@ func DefaultStyles() *Styles { SetString(">") s.MenuItem = lipgloss.NewStyle(). - PaddingLeft(2) + PaddingLeft(1). + Border(lipgloss.Border{ + Left: " ", + }, false, false, false, true). + Height(3) - s.SelectedMenuItem = lipgloss.NewStyle(). - Foreground(lipgloss.Color("207")). - PaddingLeft(1) + s.MenuLastUpdate = lipgloss.NewStyle(). + Foreground(lipgloss.Color("241")). + Align(lipgloss.Right) + + s.SelectorBox = lipgloss.NewStyle() + + s.ReadmeBox = lipgloss.NewStyle() s.RepoTitleBorder = lipgloss.Border{ Top: "─", @@ -141,6 +175,8 @@ func DefaultStyles() *Styles { BottomRight: "╯", } + s.Repo = lipgloss.NewStyle() + s.RepoTitle = lipgloss.NewStyle(). Padding(0, 2) @@ -148,8 +184,7 @@ func DefaultStyles() *Styles { BorderStyle(s.RepoTitleBorder). BorderForeground(s.InactiveBorderColor) - s.RepoNote = lipgloss.NewStyle(). - Padding(0, 2). + s.RepoCommand = lipgloss.NewStyle(). Foreground(lipgloss.Color("168")) s.RepoNoteBox = lipgloss.NewStyle(). @@ -161,12 +196,23 @@ func DefaultStyles() *Styles { BorderLeft(false) s.RepoBody = lipgloss.NewStyle(). - BorderStyle(s.RepoBodyBorder). - BorderForeground(s.InactiveBorderColor). - PaddingRight(1) + Margin(1, 0) + + s.RepoHeader = lipgloss.NewStyle(). + Height(2). + Border(lipgloss.NormalBorder(), false, false, true, false). + BorderForeground(lipgloss.Color("238")) + + s.RepoHeaderName = lipgloss.NewStyle(). + Bold(true) + + s.RepoHeaderDesc = lipgloss.NewStyle(). + Faint(true) s.Footer = lipgloss.NewStyle(). - MarginTop(1) + MarginTop(1). + Padding(0, 1). + Height(1) s.Branch = lipgloss.NewStyle(). Foreground(lipgloss.Color("203")). @@ -184,7 +230,7 @@ func DefaultStyles() *Styles { SetString(" • ") s.Error = lipgloss.NewStyle(). - Padding(1) + MarginTop(2) s.ErrorTitle = lipgloss.NewStyle(). Foreground(lipgloss.Color("230")). @@ -194,8 +240,7 @@ func DefaultStyles() *Styles { s.ErrorBody = lipgloss.NewStyle(). Foreground(lipgloss.Color("252")). - MarginLeft(2). - Width(52) // for now + MarginLeft(2) s.AboutNoReadme = lipgloss.NewStyle(). MarginTop(1). @@ -203,25 +248,32 @@ func DefaultStyles() *Styles { Foreground(lipgloss.Color("#626262")) s.LogItemInactive = lipgloss.NewStyle(). - MarginLeft(1) + Border(lipgloss.Border{ + Left: " ", + }, false, false, false, true). + PaddingLeft(1) + + s.LogItemActive = s.LogItemInactive.Copy(). + Border(lipgloss.Border{ + Left: "┃", + }, false, false, false, true). + BorderForeground(lipgloss.Color("#B083EA")) s.LogItemSelector = s.LogItemInactive.Copy(). Width(1). Foreground(lipgloss.Color("#B083EA")) - s.LogItemActive = s.LogItemInactive.Copy(). - Bold(true) - s.LogItemHash = s.LogItemInactive.Copy(). - Width(7). Foreground(lipgloss.Color("#A3A322")) + s.LogItemTitle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#B083EA")) + s.LogCommit = lipgloss.NewStyle(). Margin(0, 2) - s.LogCommitHash = s.LogItemHash.Copy(). - UnsetMarginLeft(). - UnsetWidth(). + s.LogCommitHash = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#A3A322")). Bold(true) s.LogCommitBody = lipgloss.NewStyle(). @@ -240,11 +292,16 @@ func DefaultStyles() *Styles { Margin(0). Align(lipgloss.Center) - s.RefItemSelector = s.LogItemSelector.Copy() + s.RefItemInactive = lipgloss.NewStyle(). + MarginLeft(1) - s.RefItemActive = s.LogItemActive.Copy() + s.RefItemSelector = lipgloss.NewStyle(). + Width(1). + Foreground(lipgloss.Color("#B083EA")) - s.RefItemInactive = s.LogItemInactive.Copy() + s.RefItemActive = lipgloss.NewStyle(). + MarginLeft(1). + Bold(true) s.RefItemBranch = lipgloss.NewStyle() @@ -253,20 +310,24 @@ func DefaultStyles() *Styles { s.RefPaginator = s.LogPaginator.Copy() - s.TreeItemSelector = s.LogItemSelector.Copy() + s.TreeItemSelector = s.TreeItemInactive.Copy(). + Width(1). + Foreground(lipgloss.Color("#B083EA")) - s.TreeItemActive = s.LogItemActive.Copy() + s.TreeItemInactive = lipgloss.NewStyle(). + MarginLeft(1) - s.TreeItemInactive = s.LogItemInactive.Copy() + s.TreeItemActive = s.TreeItemInactive.Copy(). + Bold(true) s.TreeFileDir = lipgloss.NewStyle(). Foreground(lipgloss.Color("#00AAFF")) - s.TreeFileMode = s.LogItemInactive.Copy(). + s.TreeFileMode = s.TreeItemInactive.Copy(). Width(10). Foreground(lipgloss.Color("#777777")) - s.TreeFileSize = s.LogItemInactive.Copy(). + s.TreeFileSize = s.TreeItemInactive.Copy(). Foreground(lipgloss.Color("252")) s.TreeFileContent = lipgloss.NewStyle() @@ -280,5 +341,53 @@ func DefaultStyles() *Styles { MarginLeft(2). Foreground(lipgloss.Color("205")) + s.CodeNoContent = lipgloss.NewStyle(). + SetString("No Content."). + MarginTop(1). + MarginLeft(2). + Foreground(lipgloss.Color("#626262")) + + s.StatusBar = lipgloss.NewStyle(). + Height(1) + + s.StatusBarKey = lipgloss.NewStyle(). + Bold(true). + Padding(0, 1). + Background(lipgloss.Color("#FF5FD2")). + Foreground(lipgloss.Color("#FFFF87")) + + s.StatusBarValue = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("235")). + Foreground(lipgloss.Color("243")) + + s.StatusBarInfo = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#FF8EC7")). + Foreground(lipgloss.Color("#F1F1F1")) + + s.StatusBarBranch = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("#6E6ED8")). + Foreground(lipgloss.Color("#F1F1F1")) + + s.StatusBarHelp = lipgloss.NewStyle(). + Padding(0, 1). + Background(lipgloss.Color("237")). + Foreground(lipgloss.Color("243")) + + s.Tabs = lipgloss.NewStyle() + + s.TabInactive = lipgloss.NewStyle() + + s.TabActive = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#6E6ED8")). + Underline(true) + + s.TabSeparator = lipgloss.NewStyle(). + SetString("│"). + Padding(0, 1). + Foreground(lipgloss.Color("238")) + return s } diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 000000000..052e5ad65 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,302 @@ +package ui + +import ( + "log" + "os" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/soft-serve/config" + "github.com/charmbracelet/soft-serve/ui/common" + "github.com/charmbracelet/soft-serve/ui/components/footer" + "github.com/charmbracelet/soft-serve/ui/components/header" + "github.com/charmbracelet/soft-serve/ui/components/selector" + "github.com/charmbracelet/soft-serve/ui/git" + "github.com/charmbracelet/soft-serve/ui/pages/repo" + "github.com/charmbracelet/soft-serve/ui/pages/selection" + "github.com/gliderlabs/ssh" +) + +type page int + +const ( + selectionPage page = iota + repoPage +) + +type sessionState int + +const ( + startState sessionState = iota + errorState + loadedState +) + +// UI is the main UI model. +type UI struct { + cfg *config.Config + session ssh.Session + rs git.GitRepoSource + initialRepo string + common common.Common + pages []common.Component + activePage page + state sessionState + header *header.Header + footer *footer.Footer + showFooter bool + error error +} + +// New returns a new UI model. +func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI { + src := &source{cfg.Source} + h := header.New(c, cfg.Name) + ui := &UI{ + cfg: cfg, + session: s, + rs: src, + common: c, + pages: make([]common.Component, 2), // selection & repo + activePage: selectionPage, + state: startState, + header: h, + initialRepo: initialRepo, + showFooter: true, + } + ui.footer = footer.New(c, ui) + return ui +} + +func (ui *UI) getMargins() (wm, hm int) { + style := ui.common.Styles.App.Copy() + switch ui.activePage { + case selectionPage: + hm += ui.common.Styles.Header.GetHeight() + + ui.common.Styles.Header.GetVerticalFrameSize() + case repoPage: + } + wm += style.GetHorizontalFrameSize() + hm += style.GetVerticalFrameSize() + if ui.showFooter { + // NOTE: we don't use the footer's style to determine the margins + // because footer.Height() is the height of the footer after applying + // the styles. + hm += ui.footer.Height() + } + return +} + +// ShortHelp implements help.KeyMap. +func (ui *UI) ShortHelp() []key.Binding { + b := make([]key.Binding, 0) + switch ui.state { + case errorState: + b = append(b, ui.common.KeyMap.Back) + case loadedState: + b = append(b, ui.pages[ui.activePage].ShortHelp()...) + } + b = append(b, + ui.common.KeyMap.Quit, + ui.common.KeyMap.Help, + ) + return b +} + +// FullHelp implements help.KeyMap. +func (ui *UI) FullHelp() [][]key.Binding { + b := make([][]key.Binding, 0) + switch ui.state { + case errorState: + b = append(b, []key.Binding{ui.common.KeyMap.Back}) + case loadedState: + b = append(b, ui.pages[ui.activePage].FullHelp()...) + } + b = append(b, []key.Binding{ + ui.common.KeyMap.Quit, + ui.common.KeyMap.Help, + }) + return b +} + +// SetSize implements common.Component. +func (ui *UI) SetSize(width, height int) { + ui.common.SetSize(width, height) + wm, hm := ui.getMargins() + ui.header.SetSize(width-wm, height-hm) + ui.footer.SetSize(width-wm, height-hm) + for _, p := range ui.pages { + if p != nil { + p.SetSize(width-wm, height-hm) + } + } +} + +// Init implements tea.Model. +func (ui *UI) Init() tea.Cmd { + ui.pages[selectionPage] = selection.New( + ui.cfg, + ui.session.PublicKey(), + ui.common, + ) + ui.pages[repoPage] = repo.New( + ui.cfg, + ui.common, + ) + ui.SetSize(ui.common.Width, ui.common.Height) + cmds := make([]tea.Cmd, 0) + cmds = append(cmds, + ui.pages[selectionPage].Init(), + ui.pages[repoPage].Init(), + ) + if ui.initialRepo != "" { + cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo)) + } + ui.state = loadedState + ui.SetSize(ui.common.Width, ui.common.Height) + return tea.Batch(cmds...) +} + +// Update implements tea.Model. +func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if os.Getenv("DEBUG") == "true" { + log.Printf("ui msg: %T", msg) + } + cmds := make([]tea.Cmd, 0) + switch msg := msg.(type) { + case tea.WindowSizeMsg: + ui.SetSize(msg.Width, msg.Height) + for i, p := range ui.pages { + m, cmd := p.Update(msg) + ui.pages[i] = m.(common.Component) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + case tea.KeyMsg, tea.MouseMsg: + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil: + ui.error = nil + ui.state = loadedState + // Always show the footer on error. + ui.showFooter = ui.footer.ShowAll() + case key.Matches(msg, ui.common.KeyMap.Help): + ui.footer.SetShowAll(!ui.footer.ShowAll()) + // Show the footer when on repo page and shot all help. + if ui.error == nil && ui.activePage == repoPage { + ui.showFooter = !ui.showFooter + } + case key.Matches(msg, ui.common.KeyMap.Quit): + return ui, tea.Quit + case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back): + ui.activePage = selectionPage + // Always show the footer on selection page. + ui.showFooter = true + } + } + case repo.RepoMsg: + ui.activePage = repoPage + // Show the footer on repo page if show all is set. + ui.showFooter = ui.footer.ShowAll() + case common.ErrorMsg: + ui.error = msg + ui.state = errorState + ui.showFooter = true + return ui, nil + case selector.SelectMsg: + switch msg.IdentifiableItem.(type) { + case selection.Item: + if ui.activePage == selectionPage { + cmds = append(cmds, ui.setRepoCmd(msg.ID())) + } + } + } + h, cmd := ui.header.Update(msg) + ui.header = h.(*header.Header) + if cmd != nil { + cmds = append(cmds, cmd) + } + f, cmd := ui.footer.Update(msg) + ui.footer = f.(*footer.Footer) + if cmd != nil { + cmds = append(cmds, cmd) + } + if ui.state == loadedState { + m, cmd := ui.pages[ui.activePage].Update(msg) + ui.pages[ui.activePage] = m.(common.Component) + if cmd != nil { + cmds = append(cmds, cmd) + } + } + // This fixes determining the height margin of the footer. + ui.SetSize(ui.common.Width, ui.common.Height) + return ui, tea.Batch(cmds...) +} + +// View implements tea.Model. +func (ui *UI) View() string { + var view string + wm, hm := ui.getMargins() + style := ui.common.Styles.App.Copy() + switch ui.state { + case startState: + view = "Loading..." + case errorState: + err := ui.common.Styles.ErrorTitle.Render("Bummer") + err += ui.common.Styles.ErrorBody.Render(ui.error.Error()) + view = ui.common.Styles.Error.Copy(). + Width(ui.common.Width - + wm - + ui.common.Styles.ErrorBody.GetHorizontalFrameSize()). + Height(ui.common.Height - + hm - + ui.common.Styles.Error.GetVerticalFrameSize()). + Render(err) + case loadedState: + view = ui.pages[ui.activePage].View() + default: + view = "Unknown state :/ this is a bug!" + } + switch ui.activePage { + case selectionPage: + view = lipgloss.JoinVertical(lipgloss.Bottom, + ui.header.View(), + view, + ) + case repoPage: + } + if ui.showFooter { + view = lipgloss.JoinVertical(lipgloss.Bottom, + view, + ui.footer.View(), + ) + } + return style.Render( + view, + ) +} + +func (ui *UI) setRepoCmd(rn string) tea.Cmd { + return func() tea.Msg { + for _, r := range ui.rs.AllRepos() { + if r.Repo() == rn { + return repo.RepoMsg(r) + } + } + return common.ErrorMsg(git.ErrMissingRepo) + } +} + +func (ui *UI) initialRepoCmd(rn string) tea.Cmd { + return func() tea.Msg { + for _, r := range ui.rs.AllRepos() { + if r.Repo() == rn { + return repo.RepoMsg(r) + } + } + return nil + } +}