diff --git a/cmd/soft/main.go b/cmd/soft/main.go deleted file mode 100644 index b6a0ea332836f24aa9afc5845adfd68d37dea736..0000000000000000000000000000000000000000 --- a/cmd/soft/main.go +++ /dev/null @@ -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) - } -} diff --git a/cmd/soft/man.go b/cmd/soft/man.go index 1ddcd306a01d149f0e65dfcb559b22521c8dcfc1..c68c73693359612a23bdaf3e87d31739e1a27cc8 100644 --- a/cmd/soft/man.go +++ b/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) } diff --git a/cmd/soft/root.go b/cmd/soft/root.go new file mode 100644 index 0000000000000000000000000000000000000000..c5576c5472bc338c830bcb8189dead3ff141d257 --- /dev/null +++ b/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) + } +} diff --git a/cmd/soft/serve.go b/cmd/soft/serve.go new file mode 100644 index 0000000000000000000000000000000000000000..9b19ef461b3229563722d2bc561936d74eb5ab2a --- /dev/null +++ b/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 + }, + } +) diff --git a/go.mod b/go.mod index c5540e378d46acd65a6ef479bf32d796eca73c2c..7dbd1d88a4a6bcb6300a055b4dbe7750d805413f 100755 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 76bc068077fec221b3e37cd9daf2b19647b47982..5ec31c619f652b6daf7826993c32f229a81d816d 100644 --- a/go.sum +++ b/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= diff --git a/ui/git/default.go b/ui/git/default.go new file mode 100644 index 0000000000000000000000000000000000000000..c8fb817107ed3a403afce7a16b2f96dfde3b692b --- /dev/null +++ b/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() +} diff --git a/ui/pages/repo/repo.go b/ui/pages/repo/repo.go index f448ab697f5e7c81a13d465f7b204e0b60fac6f4..1c5e04bbde936526bf8c7e83ff45a527ec8165a9 100644 --- a/ui/pages/repo/repo.go +++ b/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), ), ) }