650 lines
14 KiB
Go
650 lines
14 KiB
Go
package browse
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"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
|
|
SingleColumn 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
|
|
Pages *tview.Pages
|
|
CmdFlex *tview.Flex
|
|
}
|
|
|
|
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
|
|
QueueUpdateDraw func(func()) *tview.Application
|
|
WaitGroup wGroup
|
|
}
|
|
|
|
type wGroup interface {
|
|
Add(int)
|
|
Done()
|
|
Wait()
|
|
}
|
|
|
|
type fakeGroup struct{}
|
|
|
|
func (w *fakeGroup) Add(int) {}
|
|
func (w *fakeGroup) Done() {}
|
|
func (w *fakeGroup) Wait() {}
|
|
|
|
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)
|
|
ui.List.SetSelectedFunc(func(ix int, _, _ string, _ rune) {
|
|
ui.Pages.SwitchToPage("readme")
|
|
})
|
|
|
|
el := &extList{
|
|
ui: ui,
|
|
extEntries: extEntries,
|
|
app: ui.App,
|
|
opts: opts,
|
|
QueueUpdateDraw: ui.App.QueueUpdateDraw,
|
|
WaitGroup: &fakeGroup{},
|
|
}
|
|
|
|
el.Reset()
|
|
return el
|
|
}
|
|
|
|
func (el *extList) createModal() *tview.Modal {
|
|
m := tview.NewModal()
|
|
m.SetBackgroundColor(tcell.ColorPurple)
|
|
m.SetDoneFunc(func(_ int, _ string) {
|
|
el.ui.Pages.SwitchToPage("main")
|
|
el.Refresh()
|
|
})
|
|
|
|
return m
|
|
}
|
|
|
|
func (el *extList) toggleSelected(verb string) {
|
|
ee, ix := el.FindSelected()
|
|
if ix < 0 {
|
|
el.opts.Logger.Println("failed to find selected entry")
|
|
return
|
|
}
|
|
modal := el.createModal()
|
|
|
|
if (ee.Installed && verb == "install") || (!ee.Installed && verb == "remove") {
|
|
return
|
|
}
|
|
|
|
var action func() error
|
|
|
|
if !ee.Installed {
|
|
modal.SetText(fmt.Sprintf("Installing %s...", ee.FullName))
|
|
action = func() error {
|
|
repo, err := ghrepo.FromFullName(ee.FullName)
|
|
if err != nil {
|
|
el.opts.Logger.Println(fmt.Errorf("failed to install '%s': %w", ee.FullName, err))
|
|
return err
|
|
}
|
|
err = el.opts.Em.Install(repo, "")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to install %s: %w", ee.FullName, err)
|
|
}
|
|
return nil
|
|
}
|
|
} else {
|
|
modal.SetText(fmt.Sprintf("Removing %s...", ee.FullName))
|
|
action = func() error {
|
|
name := strings.TrimPrefix(ee.Name, "gh-")
|
|
err := el.opts.Em.Remove(name)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove %s: %w", ee.FullName, err)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
el.ui.CmdFlex.Clear()
|
|
el.ui.CmdFlex.AddItem(modal, 0, 1, true)
|
|
var err error
|
|
wg := el.WaitGroup
|
|
wg.Add(1)
|
|
|
|
go func() {
|
|
el.QueueUpdateDraw(func() {
|
|
el.ui.Pages.SwitchToPage("command")
|
|
wg.Add(1)
|
|
wg.Done()
|
|
go func() {
|
|
el.QueueUpdateDraw(func() {
|
|
err = action()
|
|
if err != nil {
|
|
modal.SetText(err.Error())
|
|
} else {
|
|
modalText := fmt.Sprintf("Installed %s!", ee.FullName)
|
|
if verb == "remove" {
|
|
modalText = fmt.Sprintf("Removed %s!", ee.FullName)
|
|
}
|
|
modal.SetText(modalText)
|
|
modal.AddButtons([]string{"ok"})
|
|
el.app.SetFocus(modal)
|
|
}
|
|
wg.Done()
|
|
})
|
|
}()
|
|
})
|
|
}()
|
|
|
|
// TODO blocking the app's thread and deadlocking
|
|
wg.Wait()
|
|
if err == nil {
|
|
el.toggleInstalled(ix)
|
|
}
|
|
}
|
|
|
|
func (el *extList) InstallSelected() {
|
|
el.toggleSelected("install")
|
|
}
|
|
|
|
func (el *extList) RemoveSelected() {
|
|
el.toggleSelected("remove")
|
|
}
|
|
|
|
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.Authentication().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.SetDynamicColors(true)
|
|
help.SetText("[::b]?[-:-:-]: help [::b]j/k[-:-:-]: move [::b]i[-:-:-]: install [::b]r[-:-:-]: remove [::b]w[-:-:-]: web [::b]↵[-:-:-]: view readme [::b]q[-:-:-]: quit")
|
|
|
|
cmdFlex := tview.NewFlex()
|
|
|
|
pages := tview.NewPages()
|
|
|
|
ui := uiRegistry{
|
|
App: app,
|
|
Outerflex: outerFlex,
|
|
List: list,
|
|
Pages: pages,
|
|
CmdFlex: cmdFlex,
|
|
}
|
|
|
|
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)
|
|
if !opts.SingleColumn {
|
|
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)
|
|
|
|
helpBig := tview.NewTextView()
|
|
helpBig.SetDynamicColors(true)
|
|
helpBig.SetBorderPadding(0, 0, 2, 0)
|
|
helpBig.SetText(heredoc.Doc(`
|
|
[::b]Application[-:-:-]
|
|
|
|
?: toggle help
|
|
q: quit
|
|
|
|
[::b]Navigation[-:-:-]
|
|
|
|
↓, j: scroll list of extensions down by 1
|
|
↑, k: scroll list of extensions up by 1
|
|
|
|
shift+j, space: scroll list of extensions down by 25
|
|
shift+k, ctrl+space (mac), shift+space (windows): scroll list of extensions up by 25
|
|
|
|
[::b]Extension Management[-:-:-]
|
|
|
|
i: install highlighted extension
|
|
r: remove highlighted extension
|
|
w: open highlighted extension in web browser
|
|
|
|
[::b]Filtering[-:-:-]
|
|
|
|
/: focus filter
|
|
enter: finish filtering and go back to list
|
|
escape: clear filter and reset list
|
|
|
|
[::b]Readmes[-:-:-]
|
|
|
|
enter: open highlighted extension's readme full screen
|
|
page down: scroll readme pane down
|
|
page up: scroll readme pane up
|
|
|
|
(On a mac, page down and page up are fn+down arrow and fn+up arrow)
|
|
`))
|
|
|
|
pages.AddPage("main", outerFlex, true, true)
|
|
pages.AddPage("help", helpBig, true, false)
|
|
pages.AddPage("readme", readme, true, false)
|
|
pages.AddPage("command", cmdFlex, true, false)
|
|
|
|
app.SetRoot(pages, 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
|
|
}
|
|
|
|
curPage, _ := pages.GetFrontPage()
|
|
|
|
if curPage != "main" {
|
|
if curPage == "command" {
|
|
return event
|
|
}
|
|
if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
|
|
pages.SwitchToPage("main")
|
|
return nil
|
|
}
|
|
switch curPage {
|
|
case "readme":
|
|
switch event.Key() {
|
|
case tcell.KeyPgUp:
|
|
row, col := readme.GetScrollOffset()
|
|
if row > 0 {
|
|
readme.ScrollTo(row-2, col)
|
|
}
|
|
case tcell.KeyPgDn:
|
|
row, col := readme.GetScrollOffset()
|
|
readme.ScrollTo(row+2, col)
|
|
}
|
|
case "help":
|
|
switch event.Rune() {
|
|
case '?':
|
|
pages.SwitchToPage("main")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
switch event.Rune() {
|
|
case '?':
|
|
pages.SwitchToPage("help")
|
|
return nil
|
|
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 linux/mac and not windows:
|
|
extList.PageUp()
|
|
go loadSelectedReadme()
|
|
case tcell.KeyCtrlJ:
|
|
extList.PageDown()
|
|
go loadSelectedReadme()
|
|
case tcell.KeyCtrlK:
|
|
extList.PageUp()
|
|
go loadSelectedReadme()
|
|
}
|
|
|
|
return event
|
|
})
|
|
|
|
if err := app.Run(); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|