cmd/soft/serve.go 🔗
@@ -8,8 +8,8 @@ import (
"syscall"
"time"
- "github.com/charmbracelet/soft-serve/config"
"github.com/charmbracelet/soft-serve/server"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/spf13/cobra"
)
Ayman Bagabas created
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/serve.go | 2
config/auth.go | 0
config/config.go | 294 ++++++++++++++-
config/config_test.go | 34 +
config/defaults.go | 0
config/git.go | 59 ++
config/testdata/k1.pub | 0
examples/setuid/main.go | 2
go.mod | 7
go.sum | 12
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 +++++++++++++++++
ui/styles/styles.go | 197 ++++++++--
ui/ui.go | 302 ++++++++++++++++
68 files changed, 4,637 insertions(+), 2,491 deletions(-)
@@ -8,8 +8,8 @@ import (
"syscall"
"time"
- "github.com/charmbracelet/soft-serve/config"
"github.com/charmbracelet/soft-serve/server"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/spf13/cobra"
)
@@ -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
}
@@ -1,19 +1,37 @@
package config
import (
- "os"
"testing"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/matryer/is"
)
-func TestParseMultipleKeys(t *testing.T) {
+func TestMultipleInitialKeys(t *testing.T) {
+ cfg, err := NewConfig(&config.Config{
+ RepoPath: t.TempDir(),
+ KeyPath: t.TempDir(),
+ InitialAdminKeys: []string{
+ "testdata/k1.pub",
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
+ },
+ })
is := is.New(t)
- is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
- t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
- cfg := DefaultConfig()
- is.Equal(cfg.InitialAdminKeys, []string{
- "testdata/k1.pub",
- "testdata/k2.pub",
+ is.NoErr(err)
+ err = cfg.Reload()
+ is.NoErr(err)
+ is.Equal(cfg.Users[0].PublicKeys, []string{
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
+ }) // should have both keys
+}
+
+func TestEmptyInitialKeys(t *testing.T) {
+ cfg, err := NewConfig(&config.Config{
+ RepoPath: t.TempDir(),
+ KeyPath: t.TempDir(),
})
+ is := is.New(t)
+ is.NoErr(err)
+ is.Equal(len(cfg.Users), 0) // should not have any users
}
@@ -1,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
@@ -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 (
@@ -6,8 +6,8 @@ require (
github.com/alecthomas/chroma v0.10.0
github.com/caarlos0/env/v6 v6.9.1
github.com/charmbracelet/bubbles v0.11.0
- github.com/charmbracelet/bubbletea v0.22.0
- github.com/charmbracelet/glamour v0.4.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
@@ -22,6 +22,7 @@ require (
)
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
@@ -68,6 +69,6 @@ require (
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-20220209214540-3681064d5158 // indirect
- golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+ golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
)
@@ -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=
@@ -27,11 +29,10 @@ github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQ
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/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI=
github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4=
-github.com/charmbracelet/bubbletea v0.22.0 h1:E1BTNSE3iIrq0G0X6TjGAmrQ32cGCbFDPcIuImikrUc=
-github.com/charmbracelet/bubbletea v0.22.0/go.mod h1:aoVIwlNlr5wbCB26KhxfrqAn0bMp4YpJcoOelbxApjs=
-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/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=
@@ -215,8 +216,9 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIj
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=
@@ -1,277 +0,0 @@
-package config
-
-import (
- "bytes"
- "errors"
- "io/fs"
- "log"
- "path/filepath"
- "strings"
- "sync"
- "text/template"
- "time"
-
- "golang.org/x/crypto/ssh"
- "gopkg.in/yaml.v3"
-
- "fmt"
- "os"
-
- "github.com/charmbracelet/soft-serve/config"
- "github.com/charmbracelet/soft-serve/internal/git"
- "github.com/go-git/go-billy/v5/memfs"
- ggit "github.com/go-git/go-git/v5"
- "github.com/go-git/go-git/v5/plumbing/object"
- "github.com/go-git/go-git/v5/plumbing/transport"
- "github.com/go-git/go-git/v5/storage/memory"
-)
-
-// Config is the Soft Serve configuration.
-type Config struct {
- Name string `yaml:"name"`
- Host string `yaml:"host"`
- Port int `yaml:"port"`
- AnonAccess string `yaml:"anon-access"`
- AllowKeyless bool `yaml:"allow-keyless"`
- Users []User `yaml:"users"`
- Repos []Repo `yaml:"repos"`
- Source *git.RepoSource `yaml:"-"`
- Cfg *config.Config `yaml:"-"`
- mtx sync.Mutex
-}
-
-// User contains user-level configuration for a repository.
-type User struct {
- Name string `yaml:"name"`
- Admin bool `yaml:"admin"`
- PublicKeys []string `yaml:"public-keys"`
- CollabRepos []string `yaml:"collab-repos"`
-}
-
-// Repo contains repository configuration information.
-type Repo struct {
- Name string `yaml:"name"`
- Repo string `yaml:"repo"`
- Note string `yaml:"note"`
- Private bool `yaml:"private"`
- Readme string `yaml:"readme"`
-}
-
-// NewConfig creates a new internal Config struct.
-func NewConfig(cfg *config.Config) (*Config, error) {
- var anonAccess string
- var yamlUsers string
- var displayHost string
- host := cfg.Host
- port := cfg.Port
-
- pks := make([]string, 0)
- for _, k := range cfg.InitialAdminKeys {
- if bts, err := os.ReadFile(k); err == nil {
- // pk is a file, set its contents as pk
- k = string(bts)
- }
- var pk = strings.TrimSpace(k)
- if pk == "" {
- continue
- }
- // it is a valid ssh key, nothing to do
- if _, _, _, _, err := ssh.ParseAuthorizedKey([]byte(pk)); err != nil {
- return nil, fmt.Errorf("invalid initial admin key %q: %w", k, err)
- }
- pks = append(pks, pk)
- }
-
- rs := git.NewRepoSource(cfg.RepoPath)
- c := &Config{
- Cfg: cfg,
- }
- c.Host = cfg.Host
- c.Port = port
- c.Source = rs
- if len(pks) == 0 {
- anonAccess = "read-write"
- } else {
- anonAccess = "no-access"
- }
- if host == "" {
- displayHost = "localhost"
- } else {
- displayHost = host
- }
- yamlConfig := fmt.Sprintf(defaultConfig,
- displayHost,
- port,
- anonAccess,
- len(pks) == 0,
- )
- if len(pks) == 0 {
- yamlUsers = defaultUserConfig
- } else {
- var result string
- for _, pk := range pks {
- result += fmt.Sprintf(" - %s\n", pk)
- }
- yamlUsers = fmt.Sprintf(hasKeyUserConfig, result)
- }
- yaml := fmt.Sprintf("%s%s%s", yamlConfig, yamlUsers, exampleUserConfig)
- err := c.createDefaultConfigRepo(yaml)
- if err != nil {
- return nil, err
- }
- return c, nil
-}
-
-// Reload reloads the configuration.
-func (cfg *Config) Reload() error {
- cfg.mtx.Lock()
- defer cfg.mtx.Unlock()
- err := cfg.Source.LoadRepos()
- if err != nil {
- return err
- }
- cr, err := cfg.Source.GetRepo("config")
- if err != nil {
- return err
- }
- cs, _, err := cr.LatestFile("config.yaml")
- if err != nil {
- return err
- }
- err = yaml.Unmarshal([]byte(cs), cfg)
- if err != nil {
- return fmt.Errorf("bad yaml in config.yaml: %s", err)
- }
- for _, r := range cfg.Source.AllRepos() {
- name := r.Name()
- err = r.UpdateServerInfo()
- if err != nil {
- log.Printf("error updating server info for %s: %s", name, err)
- }
- pat := "README*"
- rp := ""
- for _, rr := range cfg.Repos {
- if name == rr.Repo {
- rp = rr.Readme
- break
- }
- }
- if rp != "" {
- pat = rp
- }
- rm := ""
- fc, fp, _ := r.LatestFile(pat)
- rm = fc
- if name == "config" {
- md, err := templatize(rm, cfg)
- if err != nil {
- return err
- }
- rm = md
- }
- r.SetReadme(rm, fp)
- }
- return nil
-}
-
-func createFile(path string, content string) error {
- f, err := os.Create(path)
- if err != nil {
- return err
- }
- defer f.Close()
- _, err = f.WriteString(content)
- if err != nil {
- return err
- }
- return f.Sync()
-}
-
-func (cfg *Config) createDefaultConfigRepo(yaml string) error {
- cn := "config"
- rp := filepath.Join(cfg.Cfg.RepoPath, cn)
- rs := cfg.Source
- err := rs.LoadRepo(cn)
- if errors.Is(err, fs.ErrNotExist) {
- log.Printf("creating default config repo %s", cn)
- repo, err := ggit.PlainInit(rp, true)
- if err != nil {
- return err
- }
- repo, err = ggit.Clone(memory.NewStorage(), memfs.New(), &ggit.CloneOptions{
- URL: rp,
- })
- if err != nil && err != transport.ErrEmptyRemoteRepository {
- return err
- }
- wt, err := repo.Worktree()
- if err != nil {
- return err
- }
- rm, err := wt.Filesystem.Create("README.md")
- if err != nil {
- return err
- }
- _, err = rm.Write([]byte(defaultReadme))
- if err != nil {
- return err
- }
- _, err = wt.Add("README.md")
- if err != nil {
- return err
- }
- cf, err := wt.Filesystem.Create("config.yaml")
- if err != nil {
- return err
- }
- _, err = cf.Write([]byte(yaml))
- if err != nil {
- return err
- }
- _, err = wt.Add("config.yaml")
- if err != nil {
- return err
- }
- author := &object.Signature{
- Name: "Soft Serve Server",
- Email: "vt100@charm.sh",
- When: time.Now(),
- }
- _, err = wt.Commit("Default init", &ggit.CommitOptions{
- All: true,
- Author: author,
- })
- if err != nil {
- return err
- }
- err = repo.Push(&ggit.PushOptions{})
- if err != nil {
- return err
- }
- } else if err != nil {
- return err
- }
- return cfg.Reload()
-}
-
-func (cfg *Config) isPrivate(repo string) bool {
- for _, r := range cfg.Repos {
- if r.Repo == repo {
- return r.Private
- }
- }
- return false
-}
-
-func templatize(mdt string, tmpl interface{}) (string, error) {
- t, err := template.New("readme").Parse(mdt)
- if err != nil {
- return "", err
- }
- buf := &bytes.Buffer{}
- err = t.Execute(buf, tmpl)
- if err != nil {
- return "", err
- }
- return buf.String(), nil
-}
@@ -1,37 +0,0 @@
-package config
-
-import (
- "testing"
-
- "github.com/charmbracelet/soft-serve/config"
- "github.com/matryer/is"
-)
-
-func TestMultipleInitialKeys(t *testing.T) {
- cfg, err := NewConfig(&config.Config{
- RepoPath: t.TempDir(),
- KeyPath: t.TempDir(),
- InitialAdminKeys: []string{
- "testdata/k1.pub",
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
- },
- })
- is := is.New(t)
- is.NoErr(err)
- err = cfg.Reload()
- is.NoErr(err)
- is.Equal(cfg.Users[0].PublicKeys, []string{
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINMwLvyV3ouVrTysUYGoJdl5Vgn5BACKov+n9PlzfPwH a@b",
- "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFxIobhwtfdwN7m1TFt9wx3PsfvcAkISGPxmbmbauST8 a@b",
- }) // should have both keys
-}
-
-func TestEmptyInitialKeys(t *testing.T) {
- cfg, err := NewConfig(&config.Config{
- RepoPath: t.TempDir(),
- KeyPath: t.TempDir(),
- })
- is := is.New(t)
- is.NoErr(err)
- is.Equal(len(cfg.Users), 0) // should not have any users
-}
@@ -1,234 +0,0 @@
-package tui
-
-import (
- "fmt"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/soft-serve/internal/config"
- "github.com/charmbracelet/soft-serve/internal/tui/bubbles/repo"
- "github.com/charmbracelet/soft-serve/internal/tui/bubbles/selection"
- "github.com/charmbracelet/soft-serve/internal/tui/style"
- "github.com/charmbracelet/soft-serve/tui/common"
- "github.com/gliderlabs/ssh"
-)
-
-const (
- repoNameMaxWidth = 32
-)
-
-type sessionState int
-
-const (
- startState sessionState = iota
- errorState
- loadedState
- quittingState
- quitState
-)
-
-type SessionConfig struct {
- Width int
- Height int
- InitialRepo string
- Session ssh.Session
-}
-
-type MenuEntry struct {
- Name string `json:"name"`
- Note string `json:"note"`
- Repo string `json:"repo"`
- bubble *repo.Bubble
-}
-
-type Bubble struct {
- config *config.Config
- styles *style.Styles
- state sessionState
- error string
- width int
- height int
- initialRepo string
- repoMenu []MenuEntry
- boxes []tea.Model
- activeBox int
- repoSelect *selection.Bubble
- session ssh.Session
-
- // remember the last resize so we can re-send it when selecting a different repo.
- lastResize tea.WindowSizeMsg
-}
-
-func NewBubble(cfg *config.Config, sCfg *SessionConfig) *Bubble {
- b := &Bubble{
- config: cfg,
- styles: style.DefaultStyles(),
- width: sCfg.Width,
- height: sCfg.Height,
- repoMenu: make([]MenuEntry, 0),
- boxes: make([]tea.Model, 2),
- initialRepo: sCfg.InitialRepo,
- session: sCfg.Session,
- }
- b.state = startState
- return b
-}
-
-func (b *Bubble) Init() tea.Cmd {
- return b.setupCmd
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- cmds := make([]tea.Cmd, 0)
- switch msg := msg.(type) {
- case tea.KeyMsg:
- switch msg.String() {
- case "q", "ctrl+c":
- return b, tea.Quit
- case "tab", "shift+tab":
- b.activeBox = (b.activeBox + 1) % 2
- }
- case errMsg:
- b.error = msg.Error()
- b.state = errorState
- return b, nil
- case tea.WindowSizeMsg:
- b.lastResize = msg
- b.width = msg.Width
- b.height = msg.Height
- if b.state == loadedState {
- for i, bx := range b.boxes {
- m, cmd := bx.Update(msg)
- b.boxes[i] = m
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- }
- case selection.SelectedMsg:
- b.activeBox = 1
- rb := b.repoMenu[msg.Index].bubble
- b.boxes[1] = rb
- case selection.ActiveMsg:
- b.boxes[1] = b.repoMenu[msg.Index].bubble
- cmds = append(cmds, func() tea.Msg {
- return b.lastResize
- })
- }
- if b.state == loadedState {
- ab, cmd := b.boxes[b.activeBox].Update(msg)
- b.boxes[b.activeBox] = ab
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) viewForBox(i int) string {
- isActive := i == b.activeBox
- switch box := b.boxes[i].(type) {
- case *selection.Bubble:
- // Menu
- var s lipgloss.Style
- s = b.styles.Menu
- if isActive {
- s = s.Copy().BorderForeground(b.styles.ActiveBorderColor)
- }
- return s.Render(box.View())
- case *repo.Bubble:
- // Repo details
- box.Active = isActive
- return box.View()
- default:
- panic(fmt.Sprintf("unknown box type %T", box))
- }
-}
-
-func (b Bubble) headerView() string {
- w := b.width - b.styles.App.GetHorizontalFrameSize()
- name := ""
- if b.config != nil {
- name = b.config.Name
- }
- return b.styles.Header.Copy().Width(w).Render(name)
-}
-
-func (b Bubble) footerView() string {
- w := &strings.Builder{}
- var h []common.HelpEntry
- if b.state != errorState {
- h = []common.HelpEntry{
- {Key: "tab", Value: "section"},
- }
- if box, ok := b.boxes[b.activeBox].(common.BubbleHelper); ok {
- help := box.Help()
- for _, he := range help {
- h = append(h, he)
- }
- }
- }
- h = append(h, common.HelpEntry{Key: "q", Value: "quit"})
- for i, v := range h {
- fmt.Fprint(w, helpEntryRender(v, b.styles))
- if i != len(h)-1 {
- fmt.Fprint(w, b.styles.HelpDivider)
- }
- }
- branch := ""
- if b.state == loadedState {
- ref := b.boxes[1].(*repo.Bubble).Reference()
- branch = ref.Name().Short()
- }
- help := w.String()
- branchMaxWidth := b.width - // bubble width
- lipgloss.Width(help) - // help width
- b.styles.App.GetHorizontalFrameSize() // App paddings
- branch = b.styles.Branch.Render(common.TruncateString(branch, branchMaxWidth-1, "…"))
- gap := lipgloss.NewStyle().
- Width(b.width -
- lipgloss.Width(help) -
- lipgloss.Width(branch) -
- b.styles.App.GetHorizontalFrameSize()).
- Render("")
- footer := lipgloss.JoinHorizontal(lipgloss.Top, help, gap, branch)
- return b.styles.Footer.Render(footer)
-}
-
-func (b Bubble) errorView() string {
- s := b.styles
- str := lipgloss.JoinHorizontal(
- lipgloss.Top,
- s.ErrorTitle.Render("Bummer"),
- s.ErrorBody.Render(b.error),
- )
- h := b.height -
- s.App.GetVerticalFrameSize() -
- lipgloss.Height(b.headerView()) -
- lipgloss.Height(b.footerView()) -
- s.RepoBody.GetVerticalFrameSize() +
- 3 // TODO: this is repo header height -- get it dynamically
- return s.Error.Copy().Height(h).Render(str)
-}
-
-func (b Bubble) View() string {
- s := strings.Builder{}
- s.WriteString(b.headerView())
- s.WriteRune('\n')
- switch b.state {
- case loadedState:
- lb := b.viewForBox(0)
- rb := b.viewForBox(1)
- s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, lb, rb))
- case errorState:
- s.WriteString(b.errorView())
- }
- s.WriteRune('\n')
- s.WriteString(b.footerView())
- return b.styles.App.Render(s.String())
-}
-
-func helpEntryRender(h common.HelpEntry, s *style.Styles) string {
- return fmt.Sprintf("%s %s", s.HelpKey.Render(h.Key), s.HelpValue.Render(h.Value))
-}
@@ -1,137 +0,0 @@
-package repo
-
-import (
- "fmt"
- "strconv"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/charmbracelet/soft-serve/git"
- "github.com/charmbracelet/soft-serve/internal/tui/style"
- gitui "github.com/charmbracelet/soft-serve/tui"
- "github.com/charmbracelet/soft-serve/tui/common"
- "github.com/muesli/reflow/truncate"
- "github.com/muesli/reflow/wrap"
-)
-
-const (
- repoNameMaxWidth = 32
-)
-
-type Bubble struct {
- name string
- host string
- port int
- repo common.GitRepo
- styles *style.Styles
- width int
- widthMargin int
- height int
- heightMargin int
- box *gitui.Bubble
-
- Active bool
-}
-
-func NewBubble(repo common.GitRepo, host string, port int, styles *style.Styles, width, wm, height, hm int) *Bubble {
- b := &Bubble{
- name: repo.Name(),
- host: host,
- port: port,
- width: width,
- widthMargin: wm,
- height: height,
- heightMargin: hm,
- styles: styles,
- }
- b.repo = repo
- b.box = gitui.NewBubble(repo, styles, width, wm+styles.RepoBody.GetHorizontalBorderSize(), height, hm+lipgloss.Height(b.headerView())-styles.RepoBody.GetVerticalBorderSize())
- return b
-}
-
-func (b *Bubble) Init() tea.Cmd {
- return b.box.Init()
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- if msg.Width == b.width && msg.Height == b.height {
- return b, nil
- }
- b.width = msg.Width
- b.height = msg.Height
- }
- box, cmd := b.box.Update(msg)
- b.box = box.(*gitui.Bubble)
- return b, cmd
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
- return b.box.Help()
-}
-
-func (b Bubble) headerView() string {
- // Render repo title
- title := b.name
- if title == "config" {
- title = "Home"
- }
- title = truncate.StringWithTail(title, repoNameMaxWidth, "…")
- title = b.styles.RepoTitle.Render(title)
-
- // Render clone command
- var note string
- if b.name == "config" {
- note = ""
- } else {
- note = fmt.Sprintf("git clone %s", b.sshAddress())
- }
- noteWidth := b.width -
- b.widthMargin -
- lipgloss.Width(title) -
- b.styles.RepoTitleBox.GetHorizontalFrameSize()
- // Hard-wrap the clone command only, without the usual word-wrapping. since
- // a long repo name isn't going to be a series of space-separated "words",
- // we'll always want it to be perfectly hard-wrapped.
- note = wrap.String(note, noteWidth-b.styles.RepoNote.GetHorizontalFrameSize())
- note = b.styles.RepoNote.Copy().Width(noteWidth).Render(note)
-
- // Render borders on name and command
- height := common.Max(lipgloss.Height(title), lipgloss.Height(note))
- titleBoxStyle := b.styles.RepoTitleBox.Copy().Height(height)
- noteBoxStyle := b.styles.RepoNoteBox.Copy().Height(height)
- if b.Active {
- titleBoxStyle = titleBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
- noteBoxStyle = noteBoxStyle.BorderForeground(b.styles.ActiveBorderColor)
- }
- title = titleBoxStyle.Render(title)
- note = noteBoxStyle.Render(note)
-
- // Render
- return lipgloss.JoinHorizontal(lipgloss.Top, title, note)
-}
-
-func (b *Bubble) View() string {
- header := b.headerView()
- bs := b.styles.RepoBody.Copy()
- if b.Active {
- bs = bs.BorderForeground(b.styles.ActiveBorderColor)
- }
- body := bs.Width(b.width - b.widthMargin - b.styles.RepoBody.GetVerticalFrameSize()).
- Height(b.height - b.heightMargin - lipgloss.Height(header)).
- Render(b.box.View())
- return header + body
-}
-
-func (b *Bubble) Reference() *git.Reference {
- return b.box.Reference()
-}
-
-func (b Bubble) sshAddress() string {
- p := ":" + strconv.Itoa(int(b.port))
- if p == ":22" {
- p = ""
- }
- return fmt.Sprintf("ssh://%s%s/%s", b.host, p, b.name)
-}
@@ -1,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
-}
@@ -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
-}
@@ -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(),
- }
- }
-}
@@ -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,
@@ -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
}
@@ -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
@@ -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
}
@@ -0,0 +1,53 @@
+package config
+
+import (
+ "log"
+ "path/filepath"
+
+ "github.com/caarlos0/env/v6"
+)
+
+// Callbacks provides an interface that can be used to run callbacks on different events.
+type Callbacks interface {
+ Tui(action string)
+ Push(repo string)
+ Fetch(repo string)
+}
+
+// Config is the configuration for Soft Serve.
+type Config struct {
+ BindAddr string `env:"SOFT_SERVE_BIND_ADDRESS" envDefault:""`
+ Host string `env:"SOFT_SERVE_HOST" envDefault:"localhost"`
+ Port int `env:"SOFT_SERVE_PORT" envDefault:"23231"`
+ KeyPath string `env:"SOFT_SERVE_KEY_PATH"`
+ RepoPath string `env:"SOFT_SERVE_REPO_PATH" envDefault:".repos"`
+ InitialAdminKeys []string `env:"SOFT_SERVE_INITIAL_ADMIN_KEY" envSeparator:"\n"`
+ Callbacks Callbacks
+ ErrorLog *log.Logger
+}
+
+// DefaultConfig returns a Config with the values populated with the defaults
+// or specified environment variables.
+func DefaultConfig() *Config {
+ cfg := &Config{ErrorLog: log.Default()}
+ if err := env.Parse(cfg); err != nil {
+ log.Fatalln(err)
+ }
+ if cfg.KeyPath == "" {
+ // NB: cross-platform-compatible path
+ cfg.KeyPath = filepath.Join(".ssh", "soft_serve_server_ed25519")
+ }
+ return cfg.WithCallbacks(nil)
+}
+
+// WithCallbacks applies the given Callbacks to the configuration.
+func (c *Config) WithCallbacks(callbacks Callbacks) *Config {
+ c.Callbacks = callbacks
+ return c
+}
+
+// WithErrorLogger sets the error logger for the configuration.
+func (c *Config) WithErrorLogger(logger *log.Logger) *Config {
+ c.ErrorLog = logger
+ return c
+}
@@ -0,0 +1,19 @@
+package config
+
+import (
+ "os"
+ "testing"
+
+ "github.com/matryer/is"
+)
+
+func TestParseMultipleKeys(t *testing.T) {
+ is := is.New(t)
+ is.NoErr(os.Setenv("SOFT_SERVE_INITIAL_ADMIN_KEY", "testdata/k1.pub\ntestdata/k2.pub"))
+ t.Cleanup(func() { is.NoErr(os.Unsetenv("SOFT_SERVE_INITIAL_ADMIN_KEY")) })
+ cfg := DefaultConfig()
+ is.Equal(cfg.InitialAdminKeys, []string{
+ "testdata/k1.pub",
+ "testdata/k2.pub",
+ })
+}
@@ -4,7 +4,7 @@ import (
"context"
"fmt"
- appCfg "github.com/charmbracelet/soft-serve/internal/config"
+ appCfg "github.com/charmbracelet/soft-serve/config"
"github.com/charmbracelet/soft-serve/server/cmd"
"github.com/charmbracelet/wish"
"github.com/gliderlabs/ssh"
@@ -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
@@ -4,8 +4,8 @@ import (
"os"
"testing"
- sconfig "github.com/charmbracelet/soft-serve/config"
- "github.com/charmbracelet/soft-serve/internal/config"
+ "github.com/charmbracelet/soft-serve/config"
+ sconfig "github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/wish/testsession"
"github.com/gliderlabs/ssh"
"github.com/matryer/is"
@@ -6,15 +6,15 @@ import (
"log"
"net"
- "github.com/charmbracelet/soft-serve/config"
- appCfg "github.com/charmbracelet/soft-serve/internal/config"
- "github.com/charmbracelet/soft-serve/internal/tui"
+ appCfg "github.com/charmbracelet/soft-serve/config"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/wish"
bm "github.com/charmbracelet/wish/bubbletea"
gm "github.com/charmbracelet/wish/git"
lm "github.com/charmbracelet/wish/logging"
rm "github.com/charmbracelet/wish/recover"
"github.com/gliderlabs/ssh"
+ "github.com/muesli/termenv"
)
// Server is the Soft Serve server.
@@ -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(
@@ -7,7 +7,7 @@ import (
"testing"
"github.com/charmbracelet/keygen"
- "github.com/charmbracelet/soft-serve/config"
+ "github.com/charmbracelet/soft-serve/server/config"
"github.com/gliderlabs/ssh"
"github.com/go-git/go-git/v5"
gconfig "github.com/go-git/go-git/v5/config"
@@ -0,0 +1,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
+ }
+}
@@ -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
-}
@@ -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...)
-}
@@ -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"),
- )
-)
@@ -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()),
- )
-}
@@ -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
-}
@@ -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)
-}
@@ -1,10 +0,0 @@
-package common
-
-type BubbleHelper interface {
- Help() []HelpEntry
-}
-
-type HelpEntry struct {
- Key string
- Value string
-}
@@ -1,7 +0,0 @@
-package common
-
-import tea "github.com/charmbracelet/bubbletea"
-
-type BubbleReset interface {
- Reset() tea.Msg
-}
@@ -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
-}
@@ -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 ""
- }
-}
@@ -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()
-}
@@ -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())
-}
@@ -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()
-}
@@ -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
+}
@@ -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)
+}
@@ -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)
+ }
+}
@@ -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
+}
@@ -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), "…")
+}
@@ -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
+}
@@ -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())
+}
@@ -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))
+}
@@ -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}
+}
@@ -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,
+ ),
+ )
+}
@@ -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)
+ }
+}
@@ -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()
+}
@@ -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
+}
@@ -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)
+}
@@ -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
+}
@@ -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
+}
@@ -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,
+ )),
+ )
+}
@@ -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)
+}
@@ -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,
+ ),
+ ),
+ )
+}
@@ -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{}
+}
@@ -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)
+ }
+}
@@ -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)
+}
@@ -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{}
+}
@@ -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()))
+}
@@ -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,
+ )
+}
@@ -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
}
@@ -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
+ }
+}