Detailed changes
@@ -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)
- }
-}
@@ -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)
}
@@ -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)
+ }
+}
@@ -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
+ },
+ }
+)
@@ -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
@@ -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=
@@ -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()
+}
@@ -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),
),
)
}