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 }