gh ext browse
This commit is contained in:
parent
8617eb7df9
commit
4bc623591f
7 changed files with 1021 additions and 2 deletions
5
go.mod
5
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
|
||||
|
|
|
|||
11
go.sum
11
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=
|
||||
|
|
|
|||
527
pkg/cmd/extension/browse/browse.go
Normal file
527
pkg/cmd/extension/browse/browse.go
Normal file
|
|
@ -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
|
||||
}
|
||||
366
pkg/cmd/extension/browse/browse_test.go
Normal file
366
pkg/cmd/extension/browse/browse_test.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
33
pkg/cmd/extension/browse/rg.go
Normal file
33
pkg/cmd/extension/browse/rg.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 <name> [args]",
|
||||
Short: "Execute an installed extension",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue