gh ext browse

This commit is contained in:
vilmibm 2022-09-13 14:37:21 -07:00
parent 8617eb7df9
commit 4bc623591f
7 changed files with 1021 additions and 2 deletions

5
go.mod
View file

@ -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
View file

@ -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=

View 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
}

View 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: &reg}
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: &reg}
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())
}

View 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
}

View file

@ -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",

View file

@ -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 {