From 4bc623591f625ca03e7efd97e83397aa32c86862 Mon Sep 17 00:00:00 2001 From: vilmibm Date: Tue, 13 Sep 2022 14:37:21 -0700 Subject: [PATCH] gh ext browse --- go.mod | 5 +- go.sum | 11 +- pkg/cmd/extension/browse/browse.go | 527 ++++++++++++++++++++++++ pkg/cmd/extension/browse/browse_test.go | 366 ++++++++++++++++ pkg/cmd/extension/browse/rg.go | 33 ++ pkg/cmd/extension/command.go | 75 ++++ pkg/cmd/extension/command_test.go | 6 + 7 files changed, 1021 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/extension/browse/browse.go create mode 100644 pkg/cmd/extension/browse/browse_test.go create mode 100644 pkg/cmd/extension/browse/rg.go diff --git a/go.mod b/go.mod index 77db02b41..e0aeaba24 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 github.com/creack/pty v1.1.18 github.com/gabriel-vasile/mimetype v1.4.1 + github.com/gdamore/tcell/v2 v2.5.3 github.com/google/go-cmp v0.5.9 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/websocket v1.4.2 @@ -28,6 +29,7 @@ require ( github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 github.com/opentracing/opentracing-go v1.1.0 + github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 github.com/sourcegraph/jsonrpc2 v0.1.0 github.com/spf13/cobra v1.5.0 @@ -50,6 +52,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/fatih/color v1.7.0 // indirect + github.com/gdamore/encoding v1.0.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect @@ -63,7 +66,7 @@ require ( github.com/muesli/termenv v0.12.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/rivo/uniseg v0.2.0 // indirect + github.com/rivo/uniseg v0.4.2 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect github.com/stretchr/objx v0.4.0 // indirect diff --git a/go.sum b/go.sum index 2b1ba41e2..6e9ec1689 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,10 @@ github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= +github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0= +github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -218,9 +222,12 @@ github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFSt github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0= +github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= +github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -370,12 +377,14 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= 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= diff --git a/pkg/cmd/extension/browse/browse.go b/pkg/cmd/extension/browse/browse.go new file mode 100644 index 000000000..be605ef9c --- /dev/null +++ b/pkg/cmd/extension/browse/browse.go @@ -0,0 +1,527 @@ +package browse + +import ( + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" + + "github.com/charmbracelet/glamour" + "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "github.com/spf13/cobra" +) + +const pagingOffset = 24 + +type ExtBrowseOpts struct { + Cmd *cobra.Command + Browser ibrowser + IO *iostreams.IOStreams + Searcher search.Searcher + Em extensions.ExtensionManager + Client *http.Client + Logger *log.Logger + Cfg config.Config + Rg *readmeGetter + Debug bool +} + +type ibrowser interface { + Browse(string) error +} + +type uiRegistry struct { + // references to some of the heavily cross-referenced tview primitives. Not + // everything is in here because most things are just used once in one place + // and don't need to be easy to look up like this. + App *tview.Application + Outerflex *tview.Flex + List *tview.List + Readme *tview.TextView +} + +type extEntry struct { + URL string + Name string + FullName string + Installed bool + Official bool + description string +} + +func (e extEntry) Title() string { + var installed string + var official string + + if e.Installed { + installed = " [green](installed)" + } + + if e.Official { + official = " [yellow](official)" + } + + return fmt.Sprintf("%s%s%s", e.FullName, official, installed) +} + +func (e extEntry) Description() string { + if e.description == "" { + return "no description provided" + } + return e.description +} + +type extList struct { + ui uiRegistry + extEntries []extEntry + app *tview.Application + filter string + opts ExtBrowseOpts +} + +func newExtList(opts ExtBrowseOpts, ui uiRegistry, extEntries []extEntry) *extList { + ui.List.SetTitleColor(tcell.ColorWhite) + ui.List.SetSelectedTextColor(tcell.ColorBlack) + ui.List.SetSelectedBackgroundColor(tcell.ColorWhite) + ui.List.SetWrapAround(false) + ui.List.SetBorderPadding(1, 1, 1, 1) + + el := &extList{ + ui: ui, + extEntries: extEntries, + app: ui.App, + opts: opts, + } + + el.Reset() + return el +} + +func (el *extList) createModal() *tview.Modal { + m := tview.NewModal() + m.SetBackgroundColor(tcell.ColorPurple) + m.SetDoneFunc(func(_ int, _ string) { + el.ui.App.SetRoot(el.ui.Outerflex, true) + el.Refresh() + }) + + return m +} + +func (el *extList) InstallSelected() { + ee, ix := el.FindSelected() + if ix < 0 { + el.opts.Logger.Println("failed to find selected entry") + return + } + repo, err := ghrepo.FromFullName(ee.FullName) + if err != nil { + el.opts.Logger.Println(fmt.Errorf("failed to install '%s't: %w", ee.FullName, err)) + return + } + + modal := el.createModal() + + modal.SetText(fmt.Sprintf("Installing %s...", ee.FullName)) + el.ui.App.SetRoot(modal, true) + // I could eliminate this with a goroutine but it seems to be working fine + el.app.ForceDraw() + err = el.opts.Em.Install(repo, "") + if err != nil { + modal.SetText(fmt.Sprintf("Failed to install %s: %s", ee.FullName, err.Error())) + } else { + modal.SetText(fmt.Sprintf("Installed %s!", ee.FullName)) + modal.AddButtons([]string{"ok"}) + el.ui.App.SetFocus(modal) + } + + el.toggleInstalled(ix) +} + +func (el *extList) RemoveSelected() { + ee, ix := el.FindSelected() + if ix < 0 { + el.opts.Logger.Println("failed to find selected extension") + return + } + + modal := el.createModal() + + modal.SetText(fmt.Sprintf("Removing %s...", ee.FullName)) + el.ui.App.SetRoot(modal, true) + // I could eliminate this with a goroutine but it seems to be working fine + el.ui.App.ForceDraw() + + err := el.opts.Em.Remove(strings.TrimPrefix(ee.Name, "gh-")) + if err != nil { + modal.SetText(fmt.Sprintf("Failed to remove %s: %s", ee.FullName, err.Error())) + } else { + modal.SetText(fmt.Sprintf("Removed %s.", ee.FullName)) + modal.AddButtons([]string{"ok"}) + el.ui.App.SetFocus(modal) + } + el.toggleInstalled(ix) +} + +func (el *extList) toggleInstalled(ix int) { + ee := el.extEntries[ix] + ee.Installed = !ee.Installed + el.extEntries[ix] = ee +} + +func (el *extList) Focus() { + el.app.SetFocus(el.ui.List) +} + +func (el *extList) Refresh() { + el.Reset() + el.Filter(el.filter) +} + +func (el *extList) Reset() { + el.ui.List.Clear() + for _, ee := range el.extEntries { + el.ui.List.AddItem(ee.Title(), ee.Description(), rune(0), func() {}) + } +} + +func (el *extList) PageDown() { + el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + pagingOffset) +} + +func (el *extList) PageUp() { + i := el.ui.List.GetCurrentItem() - pagingOffset + if i < 0 { + i = 0 + } + el.ui.List.SetCurrentItem(i) +} + +func (el *extList) ScrollDown() { + el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + 1) +} + +func (el *extList) ScrollUp() { + i := el.ui.List.GetCurrentItem() - 1 + if i < 0 { + i = 0 + } + el.ui.List.SetCurrentItem(i) +} + +func (el *extList) FindSelected() (extEntry, int) { + if el.ui.List.GetItemCount() == 0 { + return extEntry{}, -1 + } + title, desc := el.ui.List.GetItemText(el.ui.List.GetCurrentItem()) + for x, e := range el.extEntries { + if e.Title() == title && e.Description() == desc { + return e, x + } + } + return extEntry{}, -1 +} + +func (el *extList) Filter(text string) { + el.filter = text + if text == "" { + return + } + el.ui.List.Clear() + for _, ee := range el.extEntries { + if strings.Contains(ee.Title()+ee.Description(), text) { + el.ui.List.AddItem(ee.Title(), ee.Description(), rune(0), func() {}) + } + } +} + +func getSelectedReadme(opts ExtBrowseOpts, readme *tview.TextView, el *extList) (string, error) { + ee, ix := el.FindSelected() + if ix < 0 { + return "", errors.New("failed to find selected entry") + } + fullName := ee.FullName + rm, err := opts.Rg.Get(fullName) + if err != nil { + return "", err + } + + _, _, wrap, _ := readme.GetInnerRect() + + // using glamour directly because if I don't horrible things happen + renderer, err := glamour.NewTermRenderer( + glamour.WithStylePath("dark"), + glamour.WithWordWrap(wrap)) + if err != nil { + return "", err + } + rendered, err := renderer.Render(rm) + if err != nil { + return "", err + } + + return rendered, nil +} + +func getExtensions(opts ExtBrowseOpts) ([]extEntry, error) { + extEntries := []extEntry{} + + installed := opts.Em.List() + + result, err := opts.Searcher.Repositories(search.Query{ + Kind: search.KindRepositories, + Limit: 1000, + Qualifiers: search.Qualifiers{ + Topic: []string{"gh-extension"}, + }, + }) + if err != nil { + return extEntries, fmt.Errorf("failed to search for extensions: %w", err) + } + + host, _ := opts.Cfg.DefaultHost() + + for _, repo := range result.Items { + if !strings.HasPrefix(repo.Name, "gh-") { + continue + } + ee := extEntry{ + URL: "https://" + host + "/" + repo.FullName, + FullName: repo.FullName, + Name: repo.Name, + description: repo.Description, + } + for _, v := range installed { + // TODO consider a Repo() on Extension interface + var installedRepo string + if u, err := git.ParseURL(v.URL()); err == nil { + if r, err := ghrepo.FromURL(u); err == nil { + installedRepo = ghrepo.FullName(r) + } + } + if repo.FullName == installedRepo { + ee.Installed = true + } + } + if repo.Owner.Login == "cli" || repo.Owner.Login == "github" { + ee.Official = true + } + + extEntries = append(extEntries, ee) + } + + return extEntries, nil +} + +func ExtBrowse(opts ExtBrowseOpts) error { + if opts.Debug { + f, err := os.CreateTemp("", "extBrowse-*.txt") + if err != nil { + return err + } + defer os.Remove(f.Name()) + + opts.Logger = log.New(f, "", log.Lshortfile) + } else { + opts.Logger = log.New(io.Discard, "", 0) + } + + opts.IO.StartProgressIndicator() + extEntries, err := getExtensions(opts) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + opts.Rg = newReadmeGetter(opts.Client, time.Hour*24) + + app := tview.NewApplication() + + outerFlex := tview.NewFlex() + innerFlex := tview.NewFlex() + + header := tview.NewTextView().SetText(fmt.Sprintf("browsing %d gh extensions", len(extEntries))) + header.SetTextAlign(tview.AlignCenter).SetTextColor(tcell.ColorWhite) + + filter := tview.NewInputField().SetLabel("filter: ") + filter.SetFieldBackgroundColor(tcell.ColorGray) + filter.SetBorderPadding(0, 0, 20, 20) + + list := tview.NewList() + + readme := tview.NewTextView() + readme.SetBorderPadding(1, 1, 0, 1) + readme.SetBorder(true).SetBorderColor(tcell.ColorPurple) + + help := tview.NewTextView() + help.SetText( + "/: filter i/r: install/remove w: open in browser pgup/pgdn: scroll readme q: quit") + help.SetTextAlign(tview.AlignCenter) + + ui := uiRegistry{ + App: app, + Outerflex: outerFlex, + List: list, + } + + extList := newExtList(opts, ui, extEntries) + + loadSelectedReadme := func() { + rendered, err := getSelectedReadme(opts, readme, extList) + if err != nil { + opts.Logger.Println(err.Error()) + readme.SetText("unable to fetch readme :(") + return + } + + app.QueueUpdateDraw(func() { + readme.SetText("") + readme.SetDynamicColors(true) + + w := tview.ANSIWriter(readme) + _, _ = w.Write([]byte(rendered)) + + readme.ScrollToBeginning() + }) + } + + filter.SetChangedFunc(func(text string) { + extList.Filter(text) + go loadSelectedReadme() + }) + + filter.SetDoneFunc(func(key tcell.Key) { + switch key { + case tcell.KeyEnter: + extList.Focus() + case tcell.KeyEscape: + filter.SetText("") + extList.Reset() + extList.Focus() + } + }) + + innerFlex.SetDirection(tview.FlexColumn) + innerFlex.AddItem(list, 0, 1, true) + innerFlex.AddItem(readme, 0, 1, false) + + outerFlex.SetDirection(tview.FlexRow) + outerFlex.AddItem(header, 1, -1, false) + outerFlex.AddItem(filter, 1, -1, false) + outerFlex.AddItem(innerFlex, 0, 1, true) + outerFlex.AddItem(help, 1, -1, false) + + app.SetRoot(outerFlex, true) + + // Force fetching of initial readme by loading it just prior to the first + // draw. The callback is removed immediately after draw. + app.SetBeforeDrawFunc(func(_ tcell.Screen) bool { + go loadSelectedReadme() + return false // returning true would halt drawing which we do not want + }) + + app.SetAfterDrawFunc(func(_ tcell.Screen) { + app.SetBeforeDrawFunc(nil) + app.SetAfterDrawFunc(nil) + }) + + app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if filter.HasFocus() { + return event + } + + switch event.Rune() { + case 'q': + app.Stop() + case 'k': + extList.ScrollUp() + readme.SetText("...fetching readme...") + go loadSelectedReadme() + case 'j': + extList.ScrollDown() + readme.SetText("...fetching readme...") + go loadSelectedReadme() + case 'w': + ee, ix := extList.FindSelected() + if ix < 0 { + opts.Logger.Println("failed to find selected entry") + return nil + } + err = opts.Browser.Browse(ee.URL) + if err != nil { + opts.Logger.Println(fmt.Errorf("could not open browser for '%s': %w", ee.URL, err)) + } + case 'i': + extList.InstallSelected() + case 'r': + extList.RemoveSelected() + case ' ': + // The shift check works on windows and not linux/mac: + if event.Modifiers()&tcell.ModShift != 0 { + extList.PageUp() + } else { + extList.PageDown() + } + go loadSelectedReadme() + case '/': + app.SetFocus(filter) + return nil + } + switch event.Key() { + case tcell.KeyUp: + extList.ScrollUp() + go loadSelectedReadme() + return nil + case tcell.KeyDown: + extList.ScrollDown() + go loadSelectedReadme() + return nil + case tcell.KeyEscape: + filter.SetText("") + extList.Reset() + case tcell.KeyCtrlSpace: + // The ctrl check works on windows/mac and not windows: + extList.PageUp() + go loadSelectedReadme() + case tcell.KeyCtrlJ: + extList.PageDown() + go loadSelectedReadme() + case tcell.KeyCtrlK: + extList.PageUp() + go loadSelectedReadme() + case tcell.KeyPgUp: + row, col := readme.GetScrollOffset() + if row > 0 { + readme.ScrollTo(row-2, col) + } + return nil + case tcell.KeyPgDn: + row, col := readme.GetScrollOffset() + readme.ScrollTo(row+2, col) + return nil + } + + return event + }) + + // Without this redirection, the git client inside of the extension manager + // will dump git output to the terminal. + opts.IO.ErrOut = io.Discard + + if err := app.Run(); err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/extension/browse/browse_test.go b/pkg/cmd/extension/browse/browse_test.go new file mode 100644 index 000000000..8cec46607 --- /dev/null +++ b/pkg/cmd/extension/browse/browse_test.go @@ -0,0 +1,366 @@ +package browse + +import ( + "encoding/base64" + "io" + "log" + "net/http" + "net/url" + "testing" + "time" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/view" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/search" + "github.com/rivo/tview" + "github.com/stretchr/testify/assert" +) + +func Test_getSelectedReadme(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + content := base64.StdEncoding.EncodeToString([]byte("lol")) + + reg.Register( + httpmock.REST("GET", "repos/cli/gh-cool/readme"), + httpmock.JSONResponse(view.RepoReadme{Content: content})) + + client := &http.Client{Transport: ®} + + rg := newReadmeGetter(client, time.Second) + opts := ExtBrowseOpts{ + Rg: rg, + } + readme := tview.NewTextView() + ui := uiRegistry{ + List: tview.NewList(), + } + extEntries := []extEntry{ + { + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + { + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "animations in your terminal", + }, + } + el := newExtList(opts, ui, extEntries) + + content, err := getSelectedReadme(opts, readme, el) + assert.NoError(t, err) + assert.Contains(t, content, "lol") +} + +func Test_getExtensionRepos(t *testing.T) { + reg := httpmock.Registry{} + defer reg.Verify(t) + + client := &http.Client{Transport: ®} + + values := url.Values{ + "page": []string{"1"}, + "per_page": []string{"100"}, + "q": []string{"topic:gh-extension"}, + } + cfg := config.NewBlankConfig() + + cfg.DefaultHostFunc = func() (string, string) { return "github.com", "" } + + reg.Register( + httpmock.QueryMatcher("GET", "search/repositories", values), + httpmock.JSONResponse(search.RepositoriesResult{ + IncompleteResults: false, + Items: []search.Repository{ + { + FullName: "vilmibm/gh-screensaver", + Name: "gh-screensaver", + Description: "terminal animations", + Owner: search.User{ + Login: "vilmibm", + }, + }, + { + FullName: "cli/gh-cool", + Name: "gh-cool", + Description: "it's just cool ok", + Owner: search.User{ + Login: "cli", + }, + }, + { + FullName: "samcoe/gh-triage", + Name: "gh-triage", + Description: "helps with triage", + Owner: search.User{ + Login: "samcoe", + }, + }, + { + FullName: "github/gh-gei", + Name: "gh-gei", + Description: "something something enterprise", + Owner: search.User{ + Login: "github", + }, + }, + }, + Total: 4, + }), + ) + + searcher := search.NewSearcher(client, "github.com") + emMock := &extensions.ExtensionManagerMock{} + emMock.ListFunc = func() []extensions.Extension { + return []extensions.Extension{ + &extensions.ExtensionMock{ + URLFunc: func() string { + return "https://github.com/vilmibm/gh-screensaver" + }, + }, + &extensions.ExtensionMock{ + URLFunc: func() string { + return "https://github.com/github/gh-gei" + }, + }, + } + } + + opts := ExtBrowseOpts{ + Searcher: searcher, + Em: emMock, + Cfg: cfg, + } + + extEntries, err := getExtensions(opts) + assert.NoError(t, err) + + expectedEntries := []extEntry{ + { + URL: "https://github.com/vilmibm/gh-screensaver", + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "terminal animations", + }, + { + URL: "https://github.com/cli/gh-cool", + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + { + URL: "https://github.com/samcoe/gh-triage", + Name: "gh-triage", + FullName: "samcoe/gh-triage", + Installed: false, + Official: false, + description: "helps with triage", + }, + { + URL: "https://github.com/github/gh-gei", + Name: "gh-gei", + FullName: "github/gh-gei", + Installed: true, + Official: true, + description: "something something enterprise", + }, + } + + assert.Equal(t, expectedEntries, extEntries) +} + +func Test_extEntry(t *testing.T) { + cases := []struct { + name string + ee extEntry + expectedTitle string + expectedDesc string + }{ + { + name: "official", + ee: extEntry{ + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + expectedTitle: "cli/gh-cool [yellow](official)", + expectedDesc: "it's just cool ok", + }, + { + name: "no description", + ee: extEntry{ + Name: "gh-nodesc", + FullName: "barryburton/gh-nodesc", + Installed: false, + Official: false, + description: "", + }, + expectedTitle: "barryburton/gh-nodesc", + expectedDesc: "no description provided", + }, + { + name: "installed", + ee: extEntry{ + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "animations in your terminal", + }, + expectedTitle: "vilmibm/gh-screensaver [green](installed)", + expectedDesc: "animations in your terminal", + }, + { + name: "neither", + ee: extEntry{ + Name: "gh-triage", + FullName: "samcoe/gh-triage", + Installed: false, + Official: false, + description: "help with triage", + }, + expectedTitle: "samcoe/gh-triage", + expectedDesc: "help with triage", + }, + { + name: "both", + ee: extEntry{ + Name: "gh-gei", + FullName: "github/gh-gei", + Installed: true, + Official: true, + description: "something something enterprise", + }, + expectedTitle: "github/gh-gei [yellow](official) [green](installed)", + expectedDesc: "something something enterprise", + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expectedTitle, tt.ee.Title()) + assert.Equal(t, tt.expectedDesc, tt.ee.Description()) + }) + } +} + +func Test_extList(t *testing.T) { + opts := ExtBrowseOpts{ + Logger: log.New(io.Discard, "", 0), + Em: &extensions.ExtensionManagerMock{ + InstallFunc: func(repo ghrepo.Interface, _ string) error { + assert.Equal(t, "cli/gh-cool", ghrepo.FullName(repo)) + return nil + }, + RemoveFunc: func(name string) error { + assert.Equal(t, "cool", name) + return nil + }, + }, + } + app := tview.NewApplication() + list := tview.NewList() + ui := uiRegistry{ + List: list, + App: app, + } + extEntries := []extEntry{ + { + Name: "gh-cool", + FullName: "cli/gh-cool", + Installed: false, + Official: true, + description: "it's just cool ok", + }, + { + Name: "gh-screensaver", + FullName: "vilmibm/gh-screensaver", + Installed: true, + Official: false, + description: "animations in your terminal", + }, + { + Name: "gh-triage", + FullName: "samcoe/gh-triage", + Installed: false, + Official: false, + description: "help with triage", + }, + { + Name: "gh-gei", + FullName: "github/gh-gei", + Installed: true, + Official: true, + description: "something something enterprise", + }, + } + + extList := newExtList(opts, ui, extEntries) + + extList.Filter("cool") + assert.Equal(t, 1, extList.ui.List.GetItemCount()) + + title, _ := extList.ui.List.GetItemText(0) + assert.Equal(t, "cli/gh-cool [yellow](official)", title) + + extList.InstallSelected() + assert.True(t, extList.extEntries[0].Installed) + + extList.Refresh() + assert.Equal(t, 1, extList.ui.List.GetItemCount()) + + title, _ = extList.ui.List.GetItemText(0) + assert.Equal(t, "cli/gh-cool [yellow](official) [green](installed)", title) + + extList.RemoveSelected() + assert.False(t, extList.extEntries[0].Installed) + + extList.Refresh() + assert.Equal(t, 1, extList.ui.List.GetItemCount()) + + title, _ = extList.ui.List.GetItemText(0) + assert.Equal(t, "cli/gh-cool [yellow](official)", title) + + extList.Reset() + assert.Equal(t, 4, extList.ui.List.GetItemCount()) + + ee, ix := extList.FindSelected() + assert.Equal(t, 0, ix) + assert.Equal(t, "cli/gh-cool [yellow](official)", ee.Title()) + + extList.ScrollDown() + ee, ix = extList.FindSelected() + assert.Equal(t, 1, ix) + assert.Equal(t, "vilmibm/gh-screensaver [green](installed)", ee.Title()) + + extList.ScrollUp() + ee, ix = extList.FindSelected() + assert.Equal(t, 0, ix) + assert.Equal(t, "cli/gh-cool [yellow](official)", ee.Title()) + + extList.PageDown() + ee, ix = extList.FindSelected() + assert.Equal(t, 3, ix) + assert.Equal(t, "github/gh-gei [yellow](official) [green](installed)", ee.Title()) + + extList.PageUp() + ee, ix = extList.FindSelected() + assert.Equal(t, 0, ix) + assert.Equal(t, "cli/gh-cool [yellow](official)", ee.Title()) +} diff --git a/pkg/cmd/extension/browse/rg.go b/pkg/cmd/extension/browse/rg.go new file mode 100644 index 000000000..4884b1779 --- /dev/null +++ b/pkg/cmd/extension/browse/rg.go @@ -0,0 +1,33 @@ +package browse + +import ( + "net/http" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/repo/view" +) + +type readmeGetter struct { + client *http.Client +} + +func newReadmeGetter(client *http.Client, cacheTTL time.Duration) *readmeGetter { + cachingClient := api.NewCachedHTTPClient(client, cacheTTL) + return &readmeGetter{ + client: cachingClient, + } +} + +func (g *readmeGetter) Get(repoFullName string) (string, error) { + repo, err := ghrepo.FromFullName(repoFullName) + if err != nil { + return "", err + } + readme, err := view.RepositoryReadme(g.client, repo, "") + if err != nil { + return "", err + } + return readme.Content, nil +} diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 995e4bfd4..618237637 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -5,12 +5,16 @@ import ( "fmt" "os" "strings" + "time" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/extension/browse" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/search" "github.com/cli/cli/v2/utils" "github.com/spf13/cobra" ) @@ -19,6 +23,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { m := f.ExtensionManager io := f.IOStreams prompter := f.Prompter + config := f.Config + browser := f.Browser extCmd := cobra.Command{ Use: "extension", @@ -53,6 +59,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(io) for _, c := range cmds { + // TODO consider a Repo() on Extension interface var repo string if u, err := git.ParseURL(c.URL()); err == nil { if r, err := ghrepo.FromURL(u); err == nil { @@ -220,6 +227,74 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return nil }, }, + func() *cobra.Command { + var debug bool + cmd := &cobra.Command{ + Use: "browse", + Short: "Enter a UI for browsing, adding, and removing extensions", + Long: heredoc.Doc(` + This command will take over your terminal and run a fully interactive + interface for browsing, adding, and removing gh extensions. + + The extension list is navigated with the arrow keys or with j/k. + Space and control+space (or control + j/k) page the list up and down. + Extension readmes can be scrolled with page up/page down keys + (fn + arrow up/down on a mac keyboard). + + For highlighted extensions, you can press: + + - w to open the extension in your web browser + - i to install the extension + - r to remove the extension + + Press / to focus the filter input. Press enter to scroll the results. + Press Escape to clear the filter and return to the full list. + + Press q to quit. + + The output of this command may be difficult to navigate for screen reader + users, users operating at high zoom and other users of assistive technology. It + is also not advised for automation scripts. We advise those users to use the + alternative command: + + gh ext search + + along with gh ext install, gh ext remove, and gh repo view. + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if !io.CanPrompt() { + return errors.New("this command runs an interactive UI and needs to be run in a terminal") + } + cfg, err := config() + if err != nil { + return err + } + host, _ := cfg.DefaultHost() + client, err := f.HttpClient() + if err != nil { + return err + } + + searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host) + + opts := browse.ExtBrowseOpts{ + Cmd: cmd, + IO: io, + Browser: browser, + Searcher: searcher, + Em: m, + Client: client, + Cfg: cfg, + Debug: debug, + } + + return browse.ExtBrowse(opts) + }, + } + cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*") + return cmd + }(), &cobra.Command{ Use: "exec [args]", Short: "Execute an installed extension", diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 46bc109af..3ef8070b4 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -572,6 +572,12 @@ func TestNewCmdExtension(t *testing.T) { }, wantStdout: "test output", }, + { + name: "browse", + args: []string{"browse"}, + wantErr: true, + errMsg: "this command runs an interactive UI and needs to be run in a terminal", + }, } for _, tt := range tests {