wip: selection readme

Ayman Bagabas created

Change summary

go.sum                                   |   4 
ui/common/error.go                       |  11 ++
ui/components/code/code.go               | 139 ++++++++++++++++++++++++++
ui/components/selector/item.go           |   1 
ui/components/selector/selector.go       |  43 ++++++-
ui/components/viewport/viewport_patch.go |  29 +++++
ui/keymap/keymap.go                      |   2 
ui/pages/selection/selection.go          |  27 ++++
ui/session/session.go                    |   3 
ui/ui.go                                 |  30 ++--
10 files changed, 260 insertions(+), 29 deletions(-)

Detailed changes

go.sum 🔗

@@ -23,10 +23,6 @@ github.com/caarlos0/env/v6 v6.9.1 h1:zOkkjM0F6ltnQ5eBX6IPI41UP/KDGEK7rRPwGCNos8k
 github.com/caarlos0/env/v6 v6.9.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc=
 github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3 h1:w2ANoiT4ubmh4Nssa3/QW1M7lj3FZkma8f8V5aBDxXM=
 github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA=
-github.com/charmbracelet/bubbles v0.10.3 h1:fKarbRaObLn/DCsZO4Y3vKCwRUzynQD9L+gGev1E/ho=
-github.com/charmbracelet/bubbles v0.10.3/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
-github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b h1:o+LFpRn1fXtu1hDJLtBFjp7tMZ8AqwSpl84w1TnUj0Y=
-github.com/charmbracelet/bubbles v0.10.4-0.20220302223835-88562515cf7b/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
 github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97 h1:NJqAUfS+JNHqodsbhLR0zD3sDkXI7skwjAwd77HXe/Q=
 github.com/charmbracelet/bubbles v0.10.4-0.20220412141214-292a1dd7ba97/go.mod h1:jOA+DUF1rjZm7gZHcNyIVW+YrBPALKfpGVdJu8UiJsA=
 github.com/charmbracelet/bubbletea v0.19.3/go.mod h1:VuXF2pToRxDUHcBUcPmCRUHRvFATM4Ckb/ql1rBl3KA=

ui/common/error.go 🔗

@@ -0,0 +1,11 @@
+package common
+
+import tea "github.com/charmbracelet/bubbletea"
+
+type ErrorMsg error
+
+func ErrorCmd(err error) tea.Cmd {
+	return func() tea.Msg {
+		return ErrorMsg(err)
+	}
+}

ui/components/code/code.go 🔗

@@ -0,0 +1,139 @@
+package code
+
+import (
+	"strings"
+
+	"github.com/alecthomas/chroma/lexers"
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/glamour"
+	gansi "github.com/charmbracelet/glamour/ansi"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	vp "github.com/charmbracelet/soft-serve/ui/components/viewport"
+	"github.com/muesli/reflow/wrap"
+	"github.com/muesli/termenv"
+)
+
+type Code struct {
+	common    common.Common
+	content   string
+	extension string
+	viewport  *vp.ViewportBubble
+}
+
+func New(c common.Common, content, extension string) *Code {
+	r := &Code{
+		common:    c,
+		content:   content,
+		extension: extension,
+		viewport: &vp.ViewportBubble{
+			Viewport: &viewport.Model{
+				MouseWheelEnabled: true,
+			},
+		},
+	}
+	return r
+}
+
+func (r *Code) SetSize(width, height int) {
+	r.common.Width = width
+	r.common.Height = height
+	r.viewport.SetSize(width, height)
+}
+
+func (r *Code) SetContent(c, ext string) tea.Cmd {
+	r.content = c
+	r.extension = ext
+	return r.Init()
+}
+
+func (r *Code) Init() tea.Cmd {
+	w := r.common.Width
+	s := r.common.Styles
+	c := r.content
+	if c == "" {
+		c = s.AboutNoReadme.Render("File is empty.")
+	}
+	f, err := renderFile(r.extension, c, w)
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	c = wrap.String(f, w)
+	r.viewport.Viewport.SetContent(c)
+	return nil
+}
+
+func (r *Code) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	v, cmd := r.viewport.Update(msg)
+	r.viewport = v.(*vp.ViewportBubble)
+	return r, cmd
+}
+
+func (r *Code) View() string {
+	return r.viewport.View()
+}
+
+func styleConfig() gansi.StyleConfig {
+	noColor := ""
+	s := glamour.DarkStyleConfig
+	s.Document.StylePrimitive.Color = &noColor
+	s.CodeBlock.Chroma.Text.Color = &noColor
+	s.CodeBlock.Chroma.Name.Color = &noColor
+	return s
+}
+
+func renderCtx() gansi.RenderContext {
+	return gansi.NewRenderContext(gansi.Options{
+		ColorProfile: termenv.TrueColor,
+		Styles:       styleConfig(),
+	})
+}
+
+func glamourize(w int, md string) (string, error) {
+	if w > 120 {
+		w = 120
+	}
+	tr, err := glamour.NewTermRenderer(
+		glamour.WithStyles(styleConfig()),
+		glamour.WithWordWrap(w),
+	)
+
+	if err != nil {
+		return "", err
+	}
+	mdt, err := tr.Render(md)
+	if err != nil {
+		return "", err
+	}
+	return mdt, nil
+}
+
+func renderFile(path, content string, width int) (string, error) {
+	lexer := lexers.Fallback
+	if path == "" {
+		lexer = lexers.Analyse(content)
+	} else {
+		lexer = lexers.Match(path)
+	}
+	lang := ""
+	if lexer != nil && lexer.Config() != nil {
+		lang = lexer.Config().Name
+	}
+	if lang == "markdown" {
+		md, err := glamourize(width, content)
+		if err != nil {
+			return "", err
+		}
+		return md, nil
+	}
+	formatter := &gansi.CodeBlockElement{
+		Code:     content,
+		Language: lang,
+	}
+	r := strings.Builder{}
+	err := formatter.Render(&r, renderCtx())
+	if err != nil {
+		return "", err
+	}
+	return r.String(), nil
+}

ui/components/selector/item.go 🔗

@@ -85,7 +85,6 @@ func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list
 	if index == m.Index() {
 		style = d.styles.SelectedMenuItem.Copy()
 	}
-	style.Width(m.Width() - 2) // FIXME figure out where this "2" comes from
 	titleStr := i.Title
 	updatedStr := fmt.Sprintf(" Updated %s", humanize.Time(i.LastUpdate))
 	updated := d.styles.MenuLastUpdate.

ui/components/selector/selector.go 🔗

@@ -1,6 +1,7 @@
 package selector
 
 import (
+	"github.com/charmbracelet/bubbles/key"
 	"github.com/charmbracelet/bubbles/list"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/soft-serve/ui/common"
@@ -9,8 +10,13 @@ import (
 type Selector struct {
 	list   list.Model
 	common common.Common
+	active int
 }
 
+type SelectMsg string
+
+type ActiveMsg string
+
 func New(common common.Common, items []list.Item) *Selector {
 	l := list.New(items, ItemDelegate{common.Styles}, common.Width, common.Height)
 	l.SetShowTitle(false)
@@ -38,23 +44,48 @@ func (s *Selector) SetItems(items []list.Item) tea.Cmd {
 	return s.list.SetItems(items)
 }
 
+func (s *Selector) Index() int {
+	return s.list.Index()
+}
+
 func (s *Selector) Init() tea.Cmd {
-	return nil
+	return s.activeCmd
 }
 
 func (s *Selector) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
-	default:
-		m, cmd := s.list.Update(msg)
-		s.list = m
-		if cmd != nil {
-			cmds = append(cmds, cmd)
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, s.common.Keymap.Select):
+			cmds = append(cmds, s.selectCmd)
 		}
 	}
+	m, cmd := s.list.Update(msg)
+	s.list = m
+	if cmd != nil {
+		cmds = append(cmds, cmd)
+	}
+	// Send ActiveMsg when index change.
+	if s.active != s.list.Index() {
+		cmds = append(cmds, s.activeCmd)
+	}
+	s.active = s.list.Index()
 	return s, tea.Batch(cmds...)
 }
 
 func (s *Selector) View() string {
 	return s.list.View()
 }
+
+func (s *Selector) selectCmd() tea.Msg {
+	item := s.list.SelectedItem()
+	i := item.(Item)
+	return SelectMsg(i.Name)
+}
+
+func (s *Selector) activeCmd() tea.Msg {
+	item := s.list.SelectedItem()
+	i := item.(Item)
+	return ActiveMsg(i.Name)
+}

ui/components/viewport/viewport_patch.go 🔗

@@ -0,0 +1,29 @@
+package viewport
+
+import (
+	"github.com/charmbracelet/bubbles/viewport"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+type ViewportBubble struct {
+	Viewport *viewport.Model
+}
+
+func (v *ViewportBubble) SetSize(width, height int) {
+	v.Viewport.Width = width
+	v.Viewport.Height = height
+}
+
+func (v *ViewportBubble) Init() tea.Cmd {
+	return nil
+}
+
+func (v *ViewportBubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	vp, cmd := v.Viewport.Update(msg)
+	v.Viewport = &vp
+	return v, cmd
+}
+
+func (v *ViewportBubble) View() string {
+	return v.Viewport.View()
+}

ui/keymap/keymap.go 🔗

@@ -19,8 +19,8 @@ func DefaultKeyMap() *KeyMap {
 
 	km.Quit = key.NewBinding(
 		key.WithKeys(
-			"ctrl-c",
 			"q",
+			"ctrl+c",
 		),
 		key.WithHelp(
 			"q",

ui/pages/selection/selection.go 🔗

@@ -10,6 +10,7 @@ import (
 	"github.com/charmbracelet/lipgloss"
 	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/components/code"
 	"github.com/charmbracelet/soft-serve/ui/components/selector"
 	"github.com/charmbracelet/soft-serve/ui/components/yankable"
 	"github.com/charmbracelet/soft-serve/ui/session"
@@ -18,6 +19,7 @@ import (
 type Selection struct {
 	s        session.Session
 	common   common.Common
+	readme   *code.Code
 	selector *selector.Selector
 }
 
@@ -25,6 +27,7 @@ func New(s session.Session, common common.Common) *Selection {
 	sel := &Selection{
 		s:        s,
 		common:   common,
+		readme:   code.New(common, "", ""),
 		selector: selector.New(common, []list.Item{}),
 	}
 	return sel
@@ -32,6 +35,7 @@ func New(s session.Session, common common.Common) *Selection {
 
 func (s *Selection) SetSize(width, height int) {
 	s.common.SetSize(width, height)
+	s.readme.SetSize(width, height)
 	s.selector.SetSize(width, height)
 }
 
@@ -109,12 +113,17 @@ func (s *Selection) Init() tea.Cmd {
 			})
 		}
 	}
-	return s.selector.SetItems(items)
+	return tea.Batch(
+		s.selector.Init(),
+		s.selector.SetItems(items),
+	)
 }
 
 func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
+	case selector.ActiveMsg:
+		cmds = append(cmds, s.changeActive(msg))
 	default:
 		m, cmd := s.selector.Update(msg)
 		s.selector = m.(*selector.Selector)
@@ -126,7 +135,21 @@ func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (s *Selection) View() string {
-	return s.selector.View()
+	return lipgloss.JoinHorizontal(
+		lipgloss.Top,
+		s.readme.View(),
+		s.selector.View(),
+	)
+}
+
+func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
+	cfg := s.s.Config()
+	r, err := cfg.Source.GetRepo(string(msg))
+	if err != nil {
+		return common.ErrorCmd(err)
+	}
+	rm, rp := r.Readme()
+	return s.readme.SetContent(rm, rp)
 }
 
 func repoUrl(cfg *appCfg.Config, name string) string {

ui/session/session.go 🔗

@@ -8,7 +8,10 @@ import (
 
 // Session is a interface representing a UI session.
 type Session interface {
+	// Send sends a message to the parent Bubble Tea program.
 	Send(tea.Msg)
+	// Config returns the app configuration.
 	Config() *appCfg.Config
+	// PublicKey returns the public key of the user.
 	PublicKey() ssh.PublicKey
 }

ui/ui.go 🔗

@@ -29,6 +29,7 @@ type UI struct {
 	state      sessionState
 	header     *header.Header
 	footer     *footer.Footer
+	error      error
 }
 
 func New(s session.Session, c common.Common, initialRepo string) *UI {
@@ -115,19 +116,16 @@ func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch {
 		case key.Matches(msg, ui.common.Keymap.Quit):
 			return ui, tea.Quit
-		default:
-			m, cmd := ui.pages[ui.activePage].Update(msg)
-			ui.pages[ui.activePage] = m.(common.Page)
-			if cmd != nil {
-				cmds = append(cmds, cmd)
-			}
-		}
-	default:
-		m, cmd := ui.pages[ui.activePage].Update(msg)
-		ui.pages[ui.activePage] = m.(common.Page)
-		if cmd != nil {
-			cmds = append(cmds, cmd)
 		}
+	case common.ErrorMsg:
+		ui.error = msg
+		ui.state = errorState
+		return ui, nil
+	}
+	m, cmd := ui.pages[ui.activePage].Update(msg)
+	ui.pages[ui.activePage] = m.(common.Page)
+	if cmd != nil {
+		cmds = append(cmds, cmd)
 	}
 	return ui, tea.Batch(cmds...)
 }
@@ -136,9 +134,11 @@ func (ui *UI) View() string {
 	s := strings.Builder{}
 	switch ui.state {
 	case startState:
-		return "Loading..."
+		return "\n Loading..."
 	case errorState:
-		return "Error"
+		err := ui.common.Styles.ErrorTitle.Render("Bummer")
+		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
+		return err
 	case loadedState:
 		s.WriteString(lipgloss.JoinVertical(
 			lipgloss.Bottom,
@@ -147,7 +147,7 @@ func (ui *UI) View() string {
 			ui.footer.View(),
 		))
 	default:
-		return "Unknown state :/ this is a bug!"
+		return "\n Unknown state :/ this is a bug!"
 	}
 	return ui.common.Styles.App.Render(s.String())
 }