github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/extension/browse/browse.go (about)

     1  package browse
     2  
     3  import (
     4  	"errors"
     5  	"fmt"
     6  	"io"
     7  	"log"
     8  	"net/http"
     9  	"os"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/charmbracelet/glamour"
    14  	"github.com/ungtb10d/cli/v2/git"
    15  	"github.com/ungtb10d/cli/v2/internal/config"
    16  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    17  	"github.com/ungtb10d/cli/v2/pkg/extensions"
    18  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    19  	"github.com/ungtb10d/cli/v2/pkg/search"
    20  	"github.com/gdamore/tcell/v2"
    21  	"github.com/rivo/tview"
    22  	"github.com/spf13/cobra"
    23  )
    24  
    25  const pagingOffset = 24
    26  
    27  type ExtBrowseOpts struct {
    28  	Cmd      *cobra.Command
    29  	Browser  ibrowser
    30  	IO       *iostreams.IOStreams
    31  	Searcher search.Searcher
    32  	Em       extensions.ExtensionManager
    33  	Client   *http.Client
    34  	Logger   *log.Logger
    35  	Cfg      config.Config
    36  	Rg       *readmeGetter
    37  	Debug    bool
    38  }
    39  
    40  type ibrowser interface {
    41  	Browse(string) error
    42  }
    43  
    44  type uiRegistry struct {
    45  	// references to some of the heavily cross-referenced tview primitives. Not
    46  	// everything is in here because most things are just used once in one place
    47  	// and don't need to be easy to look up like this.
    48  	App       *tview.Application
    49  	Outerflex *tview.Flex
    50  	List      *tview.List
    51  	Readme    *tview.TextView
    52  }
    53  
    54  type extEntry struct {
    55  	URL         string
    56  	Name        string
    57  	FullName    string
    58  	Installed   bool
    59  	Official    bool
    60  	description string
    61  }
    62  
    63  func (e extEntry) Title() string {
    64  	var installed string
    65  	var official string
    66  
    67  	if e.Installed {
    68  		installed = " [green](installed)"
    69  	}
    70  
    71  	if e.Official {
    72  		official = " [yellow](official)"
    73  	}
    74  
    75  	return fmt.Sprintf("%s%s%s", e.FullName, official, installed)
    76  }
    77  
    78  func (e extEntry) Description() string {
    79  	if e.description == "" {
    80  		return "no description provided"
    81  	}
    82  	return e.description
    83  }
    84  
    85  type extList struct {
    86  	ui         uiRegistry
    87  	extEntries []extEntry
    88  	app        *tview.Application
    89  	filter     string
    90  	opts       ExtBrowseOpts
    91  }
    92  
    93  func newExtList(opts ExtBrowseOpts, ui uiRegistry, extEntries []extEntry) *extList {
    94  	ui.List.SetTitleColor(tcell.ColorWhite)
    95  	ui.List.SetSelectedTextColor(tcell.ColorBlack)
    96  	ui.List.SetSelectedBackgroundColor(tcell.ColorWhite)
    97  	ui.List.SetWrapAround(false)
    98  	ui.List.SetBorderPadding(1, 1, 1, 1)
    99  
   100  	el := &extList{
   101  		ui:         ui,
   102  		extEntries: extEntries,
   103  		app:        ui.App,
   104  		opts:       opts,
   105  	}
   106  
   107  	el.Reset()
   108  	return el
   109  }
   110  
   111  func (el *extList) createModal() *tview.Modal {
   112  	m := tview.NewModal()
   113  	m.SetBackgroundColor(tcell.ColorPurple)
   114  	m.SetDoneFunc(func(_ int, _ string) {
   115  		el.ui.App.SetRoot(el.ui.Outerflex, true)
   116  		el.Refresh()
   117  	})
   118  
   119  	return m
   120  }
   121  
   122  func (el *extList) InstallSelected() {
   123  	ee, ix := el.FindSelected()
   124  	if ix < 0 {
   125  		el.opts.Logger.Println("failed to find selected entry")
   126  		return
   127  	}
   128  	repo, err := ghrepo.FromFullName(ee.FullName)
   129  	if err != nil {
   130  		el.opts.Logger.Println(fmt.Errorf("failed to install '%s't: %w", ee.FullName, err))
   131  		return
   132  	}
   133  
   134  	modal := el.createModal()
   135  
   136  	modal.SetText(fmt.Sprintf("Installing %s...", ee.FullName))
   137  	el.ui.App.SetRoot(modal, true)
   138  	// I could eliminate this with a goroutine but it seems to be working fine
   139  	el.app.ForceDraw()
   140  	err = el.opts.Em.Install(repo, "")
   141  	if err != nil {
   142  		modal.SetText(fmt.Sprintf("Failed to install %s: %s", ee.FullName, err.Error()))
   143  	} else {
   144  		modal.SetText(fmt.Sprintf("Installed %s!", ee.FullName))
   145  		modal.AddButtons([]string{"ok"})
   146  		el.ui.App.SetFocus(modal)
   147  	}
   148  
   149  	el.toggleInstalled(ix)
   150  }
   151  
   152  func (el *extList) RemoveSelected() {
   153  	ee, ix := el.FindSelected()
   154  	if ix < 0 {
   155  		el.opts.Logger.Println("failed to find selected extension")
   156  		return
   157  	}
   158  
   159  	modal := el.createModal()
   160  
   161  	modal.SetText(fmt.Sprintf("Removing %s...", ee.FullName))
   162  	el.ui.App.SetRoot(modal, true)
   163  	// I could eliminate this with a goroutine but it seems to be working fine
   164  	el.ui.App.ForceDraw()
   165  
   166  	err := el.opts.Em.Remove(strings.TrimPrefix(ee.Name, "gh-"))
   167  	if err != nil {
   168  		modal.SetText(fmt.Sprintf("Failed to remove %s: %s", ee.FullName, err.Error()))
   169  	} else {
   170  		modal.SetText(fmt.Sprintf("Removed %s.", ee.FullName))
   171  		modal.AddButtons([]string{"ok"})
   172  		el.ui.App.SetFocus(modal)
   173  	}
   174  	el.toggleInstalled(ix)
   175  }
   176  
   177  func (el *extList) toggleInstalled(ix int) {
   178  	ee := el.extEntries[ix]
   179  	ee.Installed = !ee.Installed
   180  	el.extEntries[ix] = ee
   181  }
   182  
   183  func (el *extList) Focus() {
   184  	el.app.SetFocus(el.ui.List)
   185  }
   186  
   187  func (el *extList) Refresh() {
   188  	el.Reset()
   189  	el.Filter(el.filter)
   190  }
   191  
   192  func (el *extList) Reset() {
   193  	el.ui.List.Clear()
   194  	for _, ee := range el.extEntries {
   195  		el.ui.List.AddItem(ee.Title(), ee.Description(), rune(0), func() {})
   196  	}
   197  }
   198  
   199  func (el *extList) PageDown() {
   200  	el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + pagingOffset)
   201  }
   202  
   203  func (el *extList) PageUp() {
   204  	i := el.ui.List.GetCurrentItem() - pagingOffset
   205  	if i < 0 {
   206  		i = 0
   207  	}
   208  	el.ui.List.SetCurrentItem(i)
   209  }
   210  
   211  func (el *extList) ScrollDown() {
   212  	el.ui.List.SetCurrentItem(el.ui.List.GetCurrentItem() + 1)
   213  }
   214  
   215  func (el *extList) ScrollUp() {
   216  	i := el.ui.List.GetCurrentItem() - 1
   217  	if i < 0 {
   218  		i = 0
   219  	}
   220  	el.ui.List.SetCurrentItem(i)
   221  }
   222  
   223  func (el *extList) FindSelected() (extEntry, int) {
   224  	if el.ui.List.GetItemCount() == 0 {
   225  		return extEntry{}, -1
   226  	}
   227  	title, desc := el.ui.List.GetItemText(el.ui.List.GetCurrentItem())
   228  	for x, e := range el.extEntries {
   229  		if e.Title() == title && e.Description() == desc {
   230  			return e, x
   231  		}
   232  	}
   233  	return extEntry{}, -1
   234  }
   235  
   236  func (el *extList) Filter(text string) {
   237  	el.filter = text
   238  	if text == "" {
   239  		return
   240  	}
   241  	el.ui.List.Clear()
   242  	for _, ee := range el.extEntries {
   243  		if strings.Contains(ee.Title()+ee.Description(), text) {
   244  			el.ui.List.AddItem(ee.Title(), ee.Description(), rune(0), func() {})
   245  		}
   246  	}
   247  }
   248  
   249  func getSelectedReadme(opts ExtBrowseOpts, readme *tview.TextView, el *extList) (string, error) {
   250  	ee, ix := el.FindSelected()
   251  	if ix < 0 {
   252  		return "", errors.New("failed to find selected entry")
   253  	}
   254  	fullName := ee.FullName
   255  	rm, err := opts.Rg.Get(fullName)
   256  	if err != nil {
   257  		return "", err
   258  	}
   259  
   260  	_, _, wrap, _ := readme.GetInnerRect()
   261  
   262  	// using glamour directly because if I don't horrible things happen
   263  	renderer, err := glamour.NewTermRenderer(
   264  		glamour.WithStylePath("dark"),
   265  		glamour.WithWordWrap(wrap))
   266  	if err != nil {
   267  		return "", err
   268  	}
   269  	rendered, err := renderer.Render(rm)
   270  	if err != nil {
   271  		return "", err
   272  	}
   273  
   274  	return rendered, nil
   275  }
   276  
   277  func getExtensions(opts ExtBrowseOpts) ([]extEntry, error) {
   278  	extEntries := []extEntry{}
   279  
   280  	installed := opts.Em.List()
   281  
   282  	result, err := opts.Searcher.Repositories(search.Query{
   283  		Kind:  search.KindRepositories,
   284  		Limit: 1000,
   285  		Qualifiers: search.Qualifiers{
   286  			Topic: []string{"gh-extension"},
   287  		},
   288  	})
   289  	if err != nil {
   290  		return extEntries, fmt.Errorf("failed to search for extensions: %w", err)
   291  	}
   292  
   293  	host, _ := opts.Cfg.DefaultHost()
   294  
   295  	for _, repo := range result.Items {
   296  		if !strings.HasPrefix(repo.Name, "gh-") {
   297  			continue
   298  		}
   299  		ee := extEntry{
   300  			URL:         "https://" + host + "/" + repo.FullName,
   301  			FullName:    repo.FullName,
   302  			Name:        repo.Name,
   303  			description: repo.Description,
   304  		}
   305  		for _, v := range installed {
   306  			// TODO consider a Repo() on Extension interface
   307  			var installedRepo string
   308  			if u, err := git.ParseURL(v.URL()); err == nil {
   309  				if r, err := ghrepo.FromURL(u); err == nil {
   310  					installedRepo = ghrepo.FullName(r)
   311  				}
   312  			}
   313  			if repo.FullName == installedRepo {
   314  				ee.Installed = true
   315  			}
   316  		}
   317  		if repo.Owner.Login == "cli" || repo.Owner.Login == "github" {
   318  			ee.Official = true
   319  		}
   320  
   321  		extEntries = append(extEntries, ee)
   322  	}
   323  
   324  	return extEntries, nil
   325  }
   326  
   327  func ExtBrowse(opts ExtBrowseOpts) error {
   328  	if opts.Debug {
   329  		f, err := os.CreateTemp("", "extBrowse-*.txt")
   330  		if err != nil {
   331  			return err
   332  		}
   333  		defer os.Remove(f.Name())
   334  
   335  		opts.Logger = log.New(f, "", log.Lshortfile)
   336  	} else {
   337  		opts.Logger = log.New(io.Discard, "", 0)
   338  	}
   339  
   340  	opts.IO.StartProgressIndicator()
   341  	extEntries, err := getExtensions(opts)
   342  	opts.IO.StopProgressIndicator()
   343  	if err != nil {
   344  		return err
   345  	}
   346  
   347  	opts.Rg = newReadmeGetter(opts.Client, time.Hour*24)
   348  
   349  	app := tview.NewApplication()
   350  
   351  	outerFlex := tview.NewFlex()
   352  	innerFlex := tview.NewFlex()
   353  
   354  	header := tview.NewTextView().SetText(fmt.Sprintf("browsing %d gh extensions", len(extEntries)))
   355  	header.SetTextAlign(tview.AlignCenter).SetTextColor(tcell.ColorWhite)
   356  
   357  	filter := tview.NewInputField().SetLabel("filter: ")
   358  	filter.SetFieldBackgroundColor(tcell.ColorGray)
   359  	filter.SetBorderPadding(0, 0, 20, 20)
   360  
   361  	list := tview.NewList()
   362  
   363  	readme := tview.NewTextView()
   364  	readme.SetBorderPadding(1, 1, 0, 1)
   365  	readme.SetBorder(true).SetBorderColor(tcell.ColorPurple)
   366  
   367  	help := tview.NewTextView()
   368  	help.SetText(
   369  		"/: filter  i/r: install/remove  w: open in browser  pgup/pgdn: scroll readme  q: quit")
   370  	help.SetTextAlign(tview.AlignCenter)
   371  
   372  	ui := uiRegistry{
   373  		App:       app,
   374  		Outerflex: outerFlex,
   375  		List:      list,
   376  	}
   377  
   378  	extList := newExtList(opts, ui, extEntries)
   379  
   380  	loadSelectedReadme := func() {
   381  		rendered, err := getSelectedReadme(opts, readme, extList)
   382  		if err != nil {
   383  			opts.Logger.Println(err.Error())
   384  			readme.SetText("unable to fetch readme :(")
   385  			return
   386  		}
   387  
   388  		app.QueueUpdateDraw(func() {
   389  			readme.SetText("")
   390  			readme.SetDynamicColors(true)
   391  
   392  			w := tview.ANSIWriter(readme)
   393  			_, _ = w.Write([]byte(rendered))
   394  
   395  			readme.ScrollToBeginning()
   396  		})
   397  	}
   398  
   399  	filter.SetChangedFunc(func(text string) {
   400  		extList.Filter(text)
   401  		go loadSelectedReadme()
   402  	})
   403  
   404  	filter.SetDoneFunc(func(key tcell.Key) {
   405  		switch key {
   406  		case tcell.KeyEnter:
   407  			extList.Focus()
   408  		case tcell.KeyEscape:
   409  			filter.SetText("")
   410  			extList.Reset()
   411  			extList.Focus()
   412  		}
   413  	})
   414  
   415  	innerFlex.SetDirection(tview.FlexColumn)
   416  	innerFlex.AddItem(list, 0, 1, true)
   417  	innerFlex.AddItem(readme, 0, 1, false)
   418  
   419  	outerFlex.SetDirection(tview.FlexRow)
   420  	outerFlex.AddItem(header, 1, -1, false)
   421  	outerFlex.AddItem(filter, 1, -1, false)
   422  	outerFlex.AddItem(innerFlex, 0, 1, true)
   423  	outerFlex.AddItem(help, 1, -1, false)
   424  
   425  	app.SetRoot(outerFlex, true)
   426  
   427  	// Force fetching of initial readme by loading it just prior to the first
   428  	// draw. The callback is removed immediately after draw.
   429  	app.SetBeforeDrawFunc(func(_ tcell.Screen) bool {
   430  		go loadSelectedReadme()
   431  		return false // returning true would halt drawing which we do not want
   432  	})
   433  
   434  	app.SetAfterDrawFunc(func(_ tcell.Screen) {
   435  		app.SetBeforeDrawFunc(nil)
   436  		app.SetAfterDrawFunc(nil)
   437  	})
   438  
   439  	app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
   440  		if filter.HasFocus() {
   441  			return event
   442  		}
   443  
   444  		switch event.Rune() {
   445  		case 'q':
   446  			app.Stop()
   447  		case 'k':
   448  			extList.ScrollUp()
   449  			readme.SetText("...fetching readme...")
   450  			go loadSelectedReadme()
   451  		case 'j':
   452  			extList.ScrollDown()
   453  			readme.SetText("...fetching readme...")
   454  			go loadSelectedReadme()
   455  		case 'w':
   456  			ee, ix := extList.FindSelected()
   457  			if ix < 0 {
   458  				opts.Logger.Println("failed to find selected entry")
   459  				return nil
   460  			}
   461  			err = opts.Browser.Browse(ee.URL)
   462  			if err != nil {
   463  				opts.Logger.Println(fmt.Errorf("could not open browser for '%s': %w", ee.URL, err))
   464  			}
   465  		case 'i':
   466  			extList.InstallSelected()
   467  		case 'r':
   468  			extList.RemoveSelected()
   469  		case ' ':
   470  			// The shift check works on windows and not linux/mac:
   471  			if event.Modifiers()&tcell.ModShift != 0 {
   472  				extList.PageUp()
   473  			} else {
   474  				extList.PageDown()
   475  			}
   476  			go loadSelectedReadme()
   477  		case '/':
   478  			app.SetFocus(filter)
   479  			return nil
   480  		}
   481  		switch event.Key() {
   482  		case tcell.KeyUp:
   483  			extList.ScrollUp()
   484  			go loadSelectedReadme()
   485  			return nil
   486  		case tcell.KeyDown:
   487  			extList.ScrollDown()
   488  			go loadSelectedReadme()
   489  			return nil
   490  		case tcell.KeyEscape:
   491  			filter.SetText("")
   492  			extList.Reset()
   493  		case tcell.KeyCtrlSpace:
   494  			// The ctrl check works on windows/mac and not windows:
   495  			extList.PageUp()
   496  			go loadSelectedReadme()
   497  		case tcell.KeyCtrlJ:
   498  			extList.PageDown()
   499  			go loadSelectedReadme()
   500  		case tcell.KeyCtrlK:
   501  			extList.PageUp()
   502  			go loadSelectedReadme()
   503  		case tcell.KeyPgUp:
   504  			row, col := readme.GetScrollOffset()
   505  			if row > 0 {
   506  				readme.ScrollTo(row-2, col)
   507  			}
   508  			return nil
   509  		case tcell.KeyPgDn:
   510  			row, col := readme.GetScrollOffset()
   511  			readme.ScrollTo(row+2, col)
   512  			return nil
   513  		}
   514  
   515  		return event
   516  	})
   517  
   518  	// Without this redirection, the git client inside of the extension manager
   519  	// will dump git output to the terminal.
   520  	opts.IO.ErrOut = io.Discard
   521  
   522  	if err := app.Run(); err != nil {
   523  		return err
   524  	}
   525  
   526  	return nil
   527  }