feat: standalone git tui

Ayman Bagabas created

* Make the tui into a standalone bubble
* Use cobra to handle the cli
* Move the SSH server to `soft serve`
* `soft` now runs the tui and takes a path argument to a git repo

Change summary

cmd/soft/main.go      |  75 ---------------
cmd/soft/man.go       |   6 
cmd/soft/root.go      | 218 +++++++++++++++++++++++++++++++++++++++++++++
cmd/soft/serve.go     |  45 +++++++++
go.mod                |   2 
go.sum                |   4 
ui/git/default.go     | 128 ++++++++++++++++++++++++++
ui/pages/repo/repo.go |  53 +++++-----
8 files changed, 425 insertions(+), 106 deletions(-)

Detailed changes

cmd/soft/main.go 🔗

@@ -1,75 +0,0 @@
-package main
-
-import (
-	"context"
-	"flag"
-	"fmt"
-	"log"
-	"os"
-	"os/signal"
-	"syscall"
-	"time"
-
-	"github.com/charmbracelet/soft-serve/server"
-	"github.com/charmbracelet/soft-serve/server/config"
-)
-
-var (
-	// Version contains the application version number. It's set via ldflags
-	// when building.
-	Version = ""
-
-	// CommitSHA contains the SHA of the commit that this application was built
-	// against. It's set via ldflags when building.
-	CommitSHA = ""
-
-	version = flag.Bool("version", false, "display version")
-)
-
-func main() {
-	flag.Usage = func() {
-		fmt.Fprintf(os.Stderr, "Soft Serve, a self-hostable Git server for the command line.\n\n")
-		flag.PrintDefaults()
-	}
-
-	flag.Parse()
-
-	if *version {
-		if len(CommitSHA) > 7 {
-			CommitSHA = CommitSHA[:7]
-		}
-		if Version == "" {
-			Version = "(built from source)"
-		}
-
-		fmt.Printf("Soft Serve %s", Version)
-		if len(CommitSHA) > 0 {
-			fmt.Printf(" (%s)", CommitSHA)
-		}
-
-		fmt.Println()
-		os.Exit(0)
-	}
-
-	cfg := config.DefaultConfig()
-	s := server.NewServer(cfg)
-
-	done := make(chan os.Signal, 1)
-	signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
-
-	log.Printf("Starting SSH server on %s:%d", cfg.BindAddr, cfg.Port)
-	go func() {
-		if err := s.Start(); err != nil {
-			log.Fatalln(err)
-		}
-	}()
-
-	<-done
-
-	log.Printf("Stopping SSH server on %s:%d", cfg.BindAddr, cfg.Port)
-	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
-	defer func() { cancel() }()
-	if err := s.Shutdown(ctx); err != nil {
-		log.Fatalln(err)
-	}
-}

cmd/soft/man.go 🔗

@@ -9,17 +9,15 @@ import (
 	"os"
 
 	"github.com/muesli/mango"
-	"github.com/muesli/mango/mflag"
+	mcobra "github.com/muesli/mango-cobra"
 	"github.com/muesli/roff"
 )
 
 func init() {
-	manPage := mango.NewManPage(1, "soft", "A self-hostable Git server for the command line").
-		WithLongDescription("Soft Serve is a self-hostable Git server for the command line.").
+	manPage := mcobra.NewManPage(1, rootCmd).
 		WithSection("Copyright", "(C) 2021-2022 Charmbracelet, Inc.\n"+
 			"Released under MIT license.")
 
-	flag.VisitAll(mflag.FlagVisitor(manPage))
 	fmt.Println(manPage.Build(roff.NewDocument()))
 	os.Exit(0)
 }

cmd/soft/root.go 🔗

@@ -0,0 +1,218 @@
+package main
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"path/filepath"
+	"runtime/debug"
+
+	"github.com/aymanbagabas/go-osc52"
+	"github.com/charmbracelet/bubbles/key"
+	tea "github.com/charmbracelet/bubbletea"
+	"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/footer"
+	"github.com/charmbracelet/soft-serve/ui/git"
+	"github.com/charmbracelet/soft-serve/ui/keymap"
+	"github.com/charmbracelet/soft-serve/ui/pages/repo"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/spf13/cobra"
+	"golang.org/x/term"
+)
+
+var (
+	// Version contains the application version number. It's set via ldflags
+	// when building.
+	Version = ""
+
+	// CommitSHA contains the SHA of the commit that this application was built
+	// against. It's set via ldflags when building.
+	CommitSHA = ""
+
+	rootCmd = &cobra.Command{
+		Use:   "soft",
+		Short: "Soft Serve, a self-hostable Git server for the command line.",
+		Long:  "Soft Serve is a self-hostable Git server for the command line.",
+		Args:  cobra.MaximumNArgs(1),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			path, err := os.Getwd()
+			if err != nil {
+				return err
+			}
+			if len(args) > 0 {
+				p := args[0]
+				if filepath.IsAbs(p) {
+					path = p
+				} else {
+					path = filepath.Join(path, p)
+				}
+			}
+			path = filepath.Clean(path)
+			w, h, _ := term.GetSize(int(os.Stdout.Fd()))
+			c := common.Common{
+				Styles: styles.DefaultStyles(),
+				KeyMap: keymap.DefaultKeyMap(),
+				Copy:   osc52.NewOutput(os.Stdout, os.Environ()),
+				Width:  w,
+				Height: h,
+			}
+			repo := repo.New(nil, c)
+			repo.BackKey.SetHelp("esc", "quit")
+			ui := &ui{
+				c:    c,
+				repo: repo,
+				path: path,
+			}
+			ui.footer = footer.New(c, ui)
+			p := tea.NewProgram(ui,
+				tea.WithMouseCellMotion(),
+				tea.WithAltScreen(),
+			)
+			if len(os.Getenv("DEBUG")) > 0 {
+				f, err := tea.LogToFile("soft.log", "")
+				if err != nil {
+					log.Fatal(err)
+				}
+				defer f.Close() // nolint: errcheck
+			}
+			return p.Start()
+		},
+	}
+)
+
+type state int
+
+const (
+	stateLoading state = iota
+	stateReady
+	stateError
+)
+
+type ui struct {
+	c      common.Common
+	state  state
+	repo   *repo.Repo
+	footer *footer.Footer
+	path   string
+	ref    *ggit.Reference
+	r      git.GitRepo
+	error  error
+}
+
+func (u *ui) ShortHelp() []key.Binding {
+	return u.repo.ShortHelp()
+}
+
+func (u *ui) FullHelp() [][]key.Binding {
+	return u.repo.FullHelp()
+}
+
+func (u *ui) SetSize(width, height int) {
+	u.c.SetSize(width, height)
+	hm := u.c.Styles.App.GetVerticalFrameSize()
+	wm := u.c.Styles.App.GetHorizontalFrameSize()
+	if u.footer.ShowAll() {
+		hm += u.footer.Height()
+	}
+	u.footer.SetSize(width-wm, height-hm)
+	u.repo.SetSize(width-wm, height-hm)
+}
+
+func (u *ui) Init() tea.Cmd {
+	r, err := git.NewRepo(u.path)
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	h, err := r.HEAD()
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	u.r = r
+	u.ref = h
+	return tea.Batch(
+		func() tea.Msg {
+			return repo.RefMsg(h)
+		},
+		func() tea.Msg {
+			return repo.RepoMsg(r)
+		},
+	)
+}
+
+func (u *ui) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	case repo.RefMsg, repo.RepoMsg:
+		if u.ref != nil && u.r != nil {
+			u.state = stateReady
+		}
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, u.c.KeyMap.Help):
+			u.footer.SetShowAll(!u.footer.ShowAll())
+		case key.Matches(msg, u.c.KeyMap.Quit), key.Matches(msg, u.c.KeyMap.Back):
+			return u, tea.Quit
+		}
+	case tea.WindowSizeMsg:
+		u.SetSize(msg.Width, msg.Height)
+	case common.ErrorMsg:
+		if u.state != stateLoading {
+			u.error = msg
+			u.state = stateError
+		}
+	}
+	r, cmd := u.repo.Update(msg)
+	u.repo = r.(*repo.Repo)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	// This fixes determining the height margin of the footer.
+	u.SetSize(u.c.Width, u.c.Height)
+	return u, tea.Batch(cmds...)
+}
+
+func (u *ui) View() string {
+	var view string
+	switch u.state {
+	case stateLoading:
+		view = "Loading..."
+	case stateReady:
+		view = u.repo.View()
+		if u.footer.ShowAll() {
+			view = lipgloss.JoinVertical(lipgloss.Top,
+				view,
+				u.footer.View(),
+			)
+		}
+	case stateError:
+		view = fmt.Sprintf("Error: %s", u.error)
+	}
+	return u.c.Styles.App.Render(view)
+}
+
+func init() {
+	if len(CommitSHA) >= 7 {
+		vt := rootCmd.VersionTemplate()
+		rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
+	}
+	if Version == "" {
+		if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
+			Version = info.Main.Version
+		} else {
+			Version = "unknown (built from source)"
+		}
+	}
+	rootCmd.Version = Version
+
+	rootCmd.AddCommand(
+		serveCmd,
+	)
+}
+
+func main() {
+	if err := rootCmd.Execute(); err != nil {
+		log.Fatal(err)
+	}
+}

cmd/soft/serve.go 🔗

@@ -0,0 +1,45 @@
+package main
+
+import (
+	"context"
+	"log"
+	"os"
+	"os/signal"
+	"syscall"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/spf13/cobra"
+)
+
+var (
+	serveCmd = &cobra.Command{
+		Use:   "serve",
+		Short: "Start a Soft Serve git server.",
+		RunE: func(cmd *cobra.Command, args []string) error {
+			cfg := config.DefaultConfig()
+			s := server.NewServer(cfg)
+
+			done := make(chan os.Signal, 1)
+			signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
+
+			log.Printf("Starting SSH server on %s:%d", cfg.BindAddr, cfg.Port)
+			go func() {
+				if err := s.Start(); err != nil {
+					log.Fatalln(err)
+				}
+			}()
+
+			<-done
+
+			log.Printf("Stopping SSH server on %s:%d", cfg.BindAddr, cfg.Port)
+			ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+			defer func() { cancel() }()
+			if err := s.Shutdown(ctx); err != nil {
+				return err
+			}
+			return nil
+		},
+	}
+)

go.mod 🔗

@@ -28,6 +28,7 @@ require (
 	github.com/gogs/git-module v1.6.0
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da
 	github.com/muesli/mango v0.1.0
+	github.com/muesli/mango-cobra v1.1.0
 	github.com/muesli/roff v0.1.0
 	github.com/spf13/cobra v1.4.0
 	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c
@@ -57,6 +58,7 @@ require (
 	github.com/microcosm-cc/bluemonday v1.0.17 // indirect
 	github.com/mitchellh/go-homedir v1.1.0 // indirect
 	github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 // indirect
+	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
 	github.com/rivo/uniseg v0.2.0 // indirect
 	github.com/sahilm/fuzzy v0.1.0 // indirect

go.sum 🔗

@@ -121,6 +121,10 @@ github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPw
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
 github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
+github.com/muesli/mango-cobra v1.1.0 h1:j/mM5omhC2Vw8pim716aMJVElIRln089XZJ2JY7Xjzc=
+github.com/muesli/mango-cobra v1.1.0/go.mod h1:lotV+49eKrAV0tTw/ONhLsiyKwM5uW5QP2OkYw4xlNc=
+github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
+github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
 github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=

ui/git/default.go 🔗

@@ -0,0 +1,128 @@
+package git
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/charmbracelet/soft-serve/git"
+	"github.com/gobwas/glob"
+)
+
+type gitRepo struct {
+	repo *git.Repository
+	desc string
+	rm   string
+	rp   string
+}
+
+// NewRepo returns a new git repository that conforms to the GitRepo interface.
+func NewRepo(path string) (GitRepo, error) {
+	repo, err := git.Open(path)
+	if err != nil {
+		return nil, err
+	}
+	return &gitRepo{repo: repo}, nil
+}
+
+func (r *gitRepo) Name() string {
+	return r.repo.Name()
+}
+
+func (r *gitRepo) Repo() string {
+	return r.repo.Name()
+}
+
+func (r *gitRepo) Description() string {
+	if r.desc != "" {
+		return r.desc
+	}
+	gd, err := r.repo.RevParse("--git-dir")
+	if err != nil {
+		return ""
+	}
+	gp := filepath.Join(r.repo.Path, gd, "description")
+	desc, err := os.ReadFile(gp)
+	if err != nil {
+		return ""
+	}
+	r.desc = strings.TrimSpace(string(desc))
+	return r.desc
+}
+
+func (r *gitRepo) IsPrivate() bool {
+	return false
+}
+
+func (r *gitRepo) Readme() (string, string) {
+	if r.rm != "" && r.rp != "" {
+		return r.rm, r.rp
+	}
+	pattern := "README*"
+	g := glob.MustCompile(pattern)
+	dir := filepath.Dir(pattern)
+	head, err := r.HEAD()
+	if err != nil {
+		return "", ""
+	}
+	t, err := r.repo.TreePath(head, dir)
+	if err != nil {
+		return "", ""
+	}
+	ents, err := t.Entries()
+	if err != nil {
+		return "", ""
+	}
+	for _, e := range ents {
+		fp := filepath.Join(dir, e.Name())
+		if e.IsTree() {
+			continue
+		}
+		if g.Match(fp) {
+			bts, err := e.Contents()
+			if err != nil {
+				return "", ""
+			}
+			r.rm = string(bts)
+			r.rp = fp
+			return r.rm, r.rp
+		}
+	}
+	return "", ""
+}
+
+func (r *gitRepo) Tree(ref *git.Reference, path string) (*git.Tree, error) {
+	return r.repo.TreePath(ref, path)
+}
+
+func (r *gitRepo) CommitsByPage(ref *git.Reference, page, size int) (git.Commits, error) {
+	return r.repo.CommitsByPage(ref, page, size)
+}
+
+func (r *gitRepo) CountCommits(ref *git.Reference) (int64, error) {
+	tc, err := r.repo.CountCommits(ref)
+	if err != nil {
+		return 0, err
+	}
+	return tc, nil
+}
+
+func (r *gitRepo) Diff(commit *git.Commit) (*git.Diff, error) {
+	diff, err := r.repo.Diff(commit)
+	if err != nil {
+		return nil, err
+	}
+	return diff, nil
+}
+
+func (r *gitRepo) HEAD() (*git.Reference, error) {
+	h, err := r.repo.HEAD()
+	if err != nil {
+		return nil, err
+	}
+	return h, nil
+}
+
+func (r *gitRepo) References() ([]*git.Reference, error) {
+	return r.repo.References()
+}

ui/pages/repo/repo.go 🔗

@@ -16,13 +16,6 @@ import (
 	"github.com/charmbracelet/soft-serve/ui/git"
 )
 
-type state int
-
-const (
-	loadingState state = iota
-	loadedState
-)
-
 type tab int
 
 const (
@@ -63,6 +56,8 @@ type Repo struct {
 	statusbar    *statusbar.StatusBar
 	boxes        []common.Component
 	ref          *ggit.Reference
+	BackKey      key.Binding
+	TabKey       key.Binding
 }
 
 // New returns a new Repo.
@@ -87,12 +82,18 @@ func New(cfg *config.Config, c common.Common) *Repo {
 		branches,
 		tags,
 	}
+	back := c.KeyMap.Back
+	back.SetHelp("esc", "back to menu")
+	tab := c.KeyMap.Section
+	tab.SetHelp("tab", "switch tab")
 	r := &Repo{
 		cfg:       cfg,
 		common:    c,
 		tabs:      tb,
 		statusbar: sb,
 		boxes:     boxes,
+		BackKey:   back,
+		TabKey:    tab,
 	}
 	return r
 }
@@ -115,12 +116,8 @@ func (r *Repo) SetSize(width, height int) {
 
 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)
+	b = append(b, r.BackKey)
+	b = append(b, r.TabKey)
 	return b
 }
 
@@ -268,7 +265,6 @@ 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()
@@ -278,23 +274,26 @@ func (r *Repo) headerView() string {
 	} 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)
+	if cfg := r.cfg; cfg != nil {
+		// 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)
+		desc = lipgloss.JoinHorizontal(lipgloss.Left,
+			desc,
+			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,
-			)),
+			truncate.Render(desc),
 		),
 	)
 }