wip: selector

Ayman Bagabas created

Change summary

server/session.go                  |  49 ++++++-------
ui/common/common.go                |  13 +++
ui/components/selector/item.go     |  95 ++++++++++++++++++++++++++++
ui/components/selector/selector.go |  54 ++++++++++++++++
ui/components/yankable/yankable.go |  32 +++++---
ui/keymap/keymap.go                |  83 ++++++++++++++++++++++++
ui/pages/selection/bubble.go       | 107 --------------------------------
ui/pages/selection/selection.go    |  97 +++++++++++++++++++++++++++++
ui/session/session.go              |  14 ++++
ui/styles/styles.go                |  14 ++-
ui/ui.go                           |  90 +++++++++++++++++++++-----
11 files changed, 481 insertions(+), 167 deletions(-)

Detailed changes

server/session.go 🔗

@@ -6,6 +6,9 @@ import (
 	tea "github.com/charmbracelet/bubbletea"
 	appCfg "github.com/charmbracelet/soft-serve/config"
 	"github.com/charmbracelet/soft-serve/ui"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/keymap"
+	"github.com/charmbracelet/soft-serve/ui/styles"
 	bm "github.com/charmbracelet/wish/bubbletea"
 	"github.com/gliderlabs/ssh"
 )
@@ -14,10 +17,7 @@ type Session struct {
 	tea.Model
 	*tea.Program
 	ssh.Session
-	Cfg         *appCfg.Config
-	width       int
-	height      int
-	initialRepo string
+	Cfg *appCfg.Config
 }
 
 func (s *Session) Config() *appCfg.Config {
@@ -28,18 +28,9 @@ func (s *Session) Send(msg tea.Msg) {
 	s.Program.Send(msg)
 }
 
-func (s *Session) Width() int {
-	return s.width
+func (s *Session) PublicKey() ssh.PublicKey {
+	return s.Session.PublicKey()
 }
-
-func (s *Session) Height() int {
-	return s.height
-}
-
-func (s *Session) InitialRepo() string {
-	return s.initialRepo
-}
-
 func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
 	return func(s ssh.Session) *tea.Program {
 		pty, _, active := s.Pty()
@@ -48,28 +39,34 @@ func SessionHandler(ac *appCfg.Config) bm.ProgramHandler {
 			return nil
 		}
 		sess := &Session{
-			Session:     s,
-			Cfg:         ac,
-			width:       pty.Window.Width,
-			height:      pty.Window.Height,
-			initialRepo: "",
+			Session: s,
+			Cfg:     ac,
 		}
 		cmd := s.Command()
-		switch len(cmd) {
-		case 0:
-			sess.initialRepo = ""
-		case 1:
-			sess.initialRepo = cmd[0]
+		initialRepo := ""
+		if len(cmd) == 1 {
+			initialRepo = cmd[0]
 		}
 		if ac.Cfg.Callbacks != nil {
 			ac.Cfg.Callbacks.Tui("new session")
 		}
-		m := ui.New(sess)
+		c := &common.Common{
+			Styles: styles.DefaultStyles(),
+			Keymap: keymap.DefaultKeyMap(),
+			Width:  pty.Window.Width,
+			Height: pty.Window.Height,
+		}
+		m := ui.New(
+			sess,
+			c,
+			initialRepo,
+		)
 		p := tea.NewProgram(m,
 			tea.WithInput(s),
 			tea.WithOutput(s),
 			tea.WithAltScreen(),
 			tea.WithoutCatchPanics(),
+			tea.WithMouseCellMotion(),
 		)
 		sess.Model = m
 		sess.Program = p

ui/common/common.go 🔗

@@ -0,0 +1,13 @@
+package common
+
+import (
+	"github.com/charmbracelet/soft-serve/ui/keymap"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+)
+
+type Common struct {
+	Styles *styles.Styles
+	Keymap *keymap.KeyMap
+	Width  int
+	Height int
+}

ui/components/selector/item.go 🔗

@@ -0,0 +1,95 @@
+package selector
+
+import (
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/components/yankable"
+	"github.com/charmbracelet/soft-serve/ui/styles"
+	"github.com/dustin/go-humanize"
+)
+
+type Item struct {
+	Title       string
+	Name        string
+	Description string
+	LastUpdate  time.Time
+	URL         *yankable.Yankable
+}
+
+func (i *Item) Init() tea.Cmd {
+	return nil
+}
+
+func (i *Item) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return i, nil
+}
+
+func (i *Item) View() string {
+	return ""
+}
+
+func (i Item) FilterValue() string { return i.Title }
+
+type ItemDelegate struct {
+	styles *styles.Styles
+}
+
+func (d ItemDelegate) Width() int {
+	width := d.styles.MenuItem.GetHorizontalFrameSize() + d.styles.MenuItem.GetWidth()
+	return width
+}
+func (d ItemDelegate) Height() int {
+	height := d.styles.MenuItem.GetVerticalFrameSize() + d.styles.MenuItem.GetHeight()
+	return height
+}
+func (d ItemDelegate) Spacing() int { return 1 }
+func (d ItemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd {
+	cmds := make([]tea.Cmd, 0)
+	for i, item := range m.VisibleItems() {
+		itm, ok := item.(Item)
+		if !ok {
+			continue
+		}
+		// FIXME check if X & Y are within the item box
+		switch msg := msg.(type) {
+		case tea.MouseMsg:
+			x := msg.X
+			y := msg.Y
+			minX := (i * d.Width())
+			maxX := minX + d.Width()
+			minY := (i * d.Height())
+			maxY := minY + d.Height()
+			// log.Printf("i: %d, x: %d, y: %d", i, x, y)
+			if y < minY || y > maxY || x < minX || x > maxX {
+				continue
+			}
+		}
+		y, cmd := itm.URL.Update(msg)
+		itm.URL = y.(*yankable.Yankable)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return tea.Batch(cmds...)
+}
+func (d ItemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) {
+	i := listItem.(Item)
+	s := strings.Builder{}
+	style := d.styles.MenuItem
+	if index == m.Index() {
+		style = d.styles.SelectedMenuItem
+	}
+	updated := d.styles.MenuLastUpdate.Render(fmt.Sprintf("Updated %s", humanize.Time(i.LastUpdate)))
+
+	s.WriteString(fmt.Sprintf("%s %s", i.Title, updated))
+	s.WriteString("\n")
+	s.WriteString(i.Description)
+	s.WriteString("\n\n")
+	s.WriteString(i.URL.View())
+	w.Write([]byte(style.Render(s.String())))
+}

ui/components/selector/selector.go 🔗

@@ -0,0 +1,54 @@
+package selector
+
+import (
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/soft-serve/ui/common"
+)
+
+type Selector struct {
+	list   list.Model
+	common *common.Common
+}
+
+func New(common *common.Common, items []list.Item) *Selector {
+	l := list.New(items, ItemDelegate{common.Styles}, common.Width, common.Height)
+	l.SetShowTitle(false)
+	l.SetShowHelp(false)
+	l.SetShowStatusBar(false)
+	l.DisableQuitKeybindings()
+	s := &Selector{
+		list:   l,
+		common: common,
+	}
+	return s
+}
+
+func (s *Selector) SetSize(width, height int) {
+	s.list.SetSize(width, height)
+}
+
+func (s *Selector) SetItems(items []list.Item) tea.Cmd {
+	return s.list.SetItems(items)
+}
+
+func (s *Selector) Init() tea.Cmd {
+	return nil
+}
+
+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)
+		}
+	}
+	return s, tea.Batch(cmds...)
+}
+
+func (s *Selector) View() string {
+	return s.list.View()
+}

ui/components/yankable/yankable.go 🔗

@@ -1,23 +1,31 @@
 package yankable
 
 import (
-	"time"
-
-	"github.com/charmbracelet/bubbles/timer"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 )
 
 type Yankable struct {
-	YankStyle lipgloss.Style
-	Style     lipgloss.Style
-	Text      string
-	timer     timer.Model
+	yankStyle lipgloss.Style
+	style     lipgloss.Style
+	text      string
 	clicked   bool
 }
 
+func New(style, yankStyle lipgloss.Style, text string) *Yankable {
+	return &Yankable{
+		yankStyle: yankStyle,
+		style:     style,
+		text:      text,
+		clicked:   false,
+	}
+}
+
+func (y *Yankable) SetText(text string) {
+	y.text = text
+}
+
 func (y *Yankable) Init() tea.Cmd {
-	y.timer = timer.New(3 * time.Second)
 	return nil
 }
 
@@ -28,9 +36,9 @@ func (y *Yankable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		switch msg.Type {
 		case tea.MouseRight:
 			y.clicked = true
-			cmds = append(cmds, y.timer.Init())
+			return y, nil
 		}
-	case timer.TimeoutMsg:
+	default:
 		y.clicked = false
 	}
 	return y, tea.Batch(cmds...)
@@ -38,7 +46,7 @@ func (y *Yankable) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 func (y *Yankable) View() string {
 	if y.clicked {
-		return y.YankStyle.Render(y.Text)
+		return y.yankStyle.String()
 	}
-	return y.Style.Render(y.Text)
+	return y.style.Render(y.text)
 }

ui/keymap/keymap.go 🔗

@@ -4,7 +4,13 @@ import "github.com/charmbracelet/bubbles/key"
 
 // KeyMap is a map of key bindings for the UI.
 type KeyMap struct {
-	Quit key.Binding
+	Quit      key.Binding
+	Up        key.Binding
+	Down      key.Binding
+	UpDown    key.Binding
+	LeftRight key.Binding
+	Arrows    key.Binding
+	Select    key.Binding
 }
 
 // DefaultKeyMap returns the default key map.
@@ -22,5 +28,80 @@ func DefaultKeyMap() *KeyMap {
 		),
 	)
 
+	km.Up = key.NewBinding(
+		key.WithKeys(
+			"up",
+			"k",
+		),
+		key.WithHelp(
+			"↑",
+			"up",
+		),
+	)
+
+	km.Down = key.NewBinding(
+		key.WithKeys(
+			"down",
+			"j",
+		),
+		key.WithHelp(
+			"↓",
+			"down",
+		),
+	)
+
+	km.UpDown = key.NewBinding(
+		key.WithKeys(
+			"up",
+			"down",
+			"k",
+			"j",
+		),
+		key.WithHelp(
+			"↑↓",
+			"navigate",
+		),
+	)
+
+	km.LeftRight = key.NewBinding(
+		key.WithKeys(
+			"left",
+			"h",
+			"right",
+			"l",
+		),
+		key.WithHelp(
+			"←→",
+			"navigate",
+		),
+	)
+
+	km.Arrows = key.NewBinding(
+		key.WithKeys(
+			"up",
+			"right",
+			"down",
+			"left",
+			"k",
+			"j",
+			"h",
+			"l",
+		),
+		key.WithHelp(
+			"↑←↓→",
+			"navigate",
+		),
+	)
+
+	km.Select = key.NewBinding(
+		key.WithKeys(
+			"enter",
+		),
+		key.WithHelp(
+			"enter",
+			"select",
+		),
+	)
+
 	return km
 }

ui/pages/selection/bubble.go 🔗

@@ -1,107 +0,0 @@
-package selection
-
-import (
-	"strings"
-
-	tea "github.com/charmbracelet/bubbletea"
-	"github.com/charmbracelet/lipgloss"
-	"github.com/charmbracelet/soft-serve/internal/tui/style"
-	"github.com/charmbracelet/soft-serve/tui/common"
-	"github.com/muesli/reflow/truncate"
-)
-
-type SelectedMsg struct {
-	Name  string
-	Index int
-}
-
-type ActiveMsg struct {
-	Name  string
-	Index int
-}
-
-type Bubble struct {
-	Items        []string
-	SelectedItem int
-	styles       *style.Styles
-}
-
-func NewBubble(items []string, styles *style.Styles) *Bubble {
-	return &Bubble{
-		Items:  items,
-		styles: styles,
-	}
-}
-
-func (b *Bubble) Init() tea.Cmd {
-	return nil
-}
-
-func (b Bubble) View() string {
-	s := strings.Builder{}
-	repoNameMaxWidth := b.styles.Menu.GetWidth() - // menu width
-		b.styles.Menu.GetHorizontalPadding() - // menu padding
-		lipgloss.Width(b.styles.MenuCursor.String()) - // cursor
-		b.styles.MenuItem.GetHorizontalFrameSize() // menu item gaps
-	for i, item := range b.Items {
-		item := truncate.StringWithTail(item, uint(repoNameMaxWidth), "…")
-		if i == b.SelectedItem {
-			s.WriteString(b.styles.MenuCursor.String())
-			s.WriteString(b.styles.SelectedMenuItem.Render(item))
-		} else {
-			s.WriteString(b.styles.MenuItem.Render(item))
-		}
-		if i < len(b.Items)-1 {
-			s.WriteRune('\n')
-		}
-	}
-	return s.String()
-}
-
-func (b *Bubble) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	cmds := make([]tea.Cmd, 0)
-	switch msg := msg.(type) {
-	case tea.KeyMsg:
-		switch msg.String() {
-		case "k", "up":
-			if b.SelectedItem > 0 {
-				b.SelectedItem--
-				cmds = append(cmds, b.sendActiveMessage)
-			}
-		case "j", "down":
-			if b.SelectedItem < len(b.Items)-1 {
-				b.SelectedItem++
-				cmds = append(cmds, b.sendActiveMessage)
-			}
-		case "enter":
-			cmds = append(cmds, b.sendSelectedMessage)
-		}
-	}
-	return b, tea.Batch(cmds...)
-}
-
-func (b *Bubble) Help() []common.HelpEntry {
-	return []common.HelpEntry{
-		{Key: "↑/↓", Value: "navigate"},
-	}
-}
-
-func (b *Bubble) sendActiveMessage() tea.Msg {
-	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
-		return ActiveMsg{
-			Name:  b.Items[b.SelectedItem],
-			Index: b.SelectedItem,
-		}
-	}
-	return nil
-}
-
-func (b *Bubble) sendSelectedMessage() tea.Msg {
-	if b.SelectedItem >= 0 && b.SelectedItem < len(b.Items) {
-		return SelectedMsg{
-			Name:  b.Items[b.SelectedItem],
-			Index: b.SelectedItem,
-		}
-	}
-	return nil
-}

ui/pages/selection/selection.go 🔗

@@ -0,0 +1,97 @@
+package selection
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/charmbracelet/bubbles/list"
+	tea "github.com/charmbracelet/bubbletea"
+	"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/selector"
+	"github.com/charmbracelet/soft-serve/ui/components/yankable"
+	"github.com/charmbracelet/soft-serve/ui/session"
+)
+
+type Selection struct {
+	s        session.Session
+	common   *common.Common
+	selector *selector.Selector
+}
+
+func New(s session.Session, common *common.Common) *Selection {
+	sel := &Selection{
+		s:        s,
+		common:   common,
+		selector: selector.New(common, []list.Item{}),
+	}
+	return sel
+}
+
+func (s *Selection) Init() tea.Cmd {
+	items := make([]list.Item, 0)
+	cfg := s.s.Config()
+	yank := func(text string) *yankable.Yankable {
+		return yankable.New(
+			lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
+			lipgloss.NewStyle().Foreground(lipgloss.Color("168")).SetString("Copied!"),
+			text,
+		)
+	}
+	// Put configured repos first
+	for _, r := range cfg.Repos {
+		items = append(items, selector.Item{
+			Title:       r.Name,
+			Name:        r.Repo,
+			Description: r.Note,
+			LastUpdate:  time.Now(),
+			URL:         yank(repoUrl(cfg, r.Name)),
+		})
+	}
+	for _, r := range cfg.Source.AllRepos() {
+		exists := false
+		for _, item := range items {
+			item := item.(selector.Item)
+			if item.Name == r.Name() {
+				exists = true
+				break
+			}
+		}
+		if !exists {
+			items = append(items, selector.Item{
+				Title:       r.Name(),
+				Name:        r.Name(),
+				Description: "",
+				LastUpdate:  time.Now(),
+				URL:         yank(repoUrl(cfg, r.Name())),
+			})
+		}
+	}
+	return s.selector.SetItems(items)
+}
+
+func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	cmds := make([]tea.Cmd, 0)
+	switch msg := msg.(type) {
+	default:
+		m, cmd := s.selector.Update(msg)
+		s.selector = m.(*selector.Selector)
+		if cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	}
+	return s, tea.Batch(cmds...)
+}
+
+func (s *Selection) View() string {
+	return s.selector.View()
+}
+
+func repoUrl(cfg *appCfg.Config, name string) string {
+	port := ""
+	if cfg.Port != 22 {
+		port += fmt.Sprintf(":%d", cfg.Port)
+	}
+	return fmt.Sprintf("git clone ssh://%s/%s", cfg.Host+port, name)
+}

ui/session/session.go 🔗

@@ -0,0 +1,14 @@
+package session
+
+import (
+	tea "github.com/charmbracelet/bubbletea"
+	appCfg "github.com/charmbracelet/soft-serve/config"
+	"github.com/gliderlabs/ssh"
+)
+
+// Session is a interface representing a UI session.
+type Session interface {
+	Send(tea.Msg)
+	Config() *appCfg.Config
+	PublicKey() ssh.PublicKey
+}

ui/styles/styles.go 🔗

@@ -18,6 +18,7 @@ type Styles struct {
 	Menu             lipgloss.Style
 	MenuCursor       lipgloss.Style
 	MenuItem         lipgloss.Style
+	MenuLastUpdate   lipgloss.Style
 	SelectedMenuItem lipgloss.Style
 
 	RepoTitleBorder lipgloss.Border
@@ -102,11 +103,16 @@ func DefaultStyles() *Styles {
 		SetString(">")
 
 	s.MenuItem = lipgloss.NewStyle().
-		PaddingLeft(2)
+		Padding(1, 2).
+		Height(4).
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(lipgloss.Color("241"))
+
+	s.MenuLastUpdate = lipgloss.NewStyle().
+		Foreground(lipgloss.Color("241"))
 
-	s.SelectedMenuItem = lipgloss.NewStyle().
-		Foreground(lipgloss.Color("207")).
-		PaddingLeft(1)
+	s.SelectedMenuItem = s.MenuItem.Copy().
+		BorderForeground(s.ActiveBorderColor)
 
 	s.RepoTitleBorder = lipgloss.Border{
 		Top:         "─",

ui/ui.go 🔗

@@ -3,47 +3,103 @@ package ui
 import (
 	"github.com/charmbracelet/bubbles/key"
 	tea "github.com/charmbracelet/bubbletea"
-	appCfg "github.com/charmbracelet/soft-serve/config"
-	"github.com/charmbracelet/soft-serve/ui/keymap"
+	"github.com/charmbracelet/soft-serve/ui/common"
+	"github.com/charmbracelet/soft-serve/ui/pages/selection"
+	"github.com/charmbracelet/soft-serve/ui/session"
 )
 
-type Session interface {
-	Send(tea.Msg)
-	Config() *appCfg.Config
-	Width() int
-	Height() int
-	InitialRepo() string
-}
+type sessionState int
+
+const (
+	startState sessionState = iota
+	errorState
+	loadedState
+)
 
 type UI struct {
-	s    Session
-	keys *keymap.KeyMap
+	s          session.Session
+	common     *common.Common
+	pages      []tea.Model
+	activePage int
+	state      sessionState
 }
 
-func New(s Session) *UI {
+func New(s session.Session, common *common.Common, initialRepo string) *UI {
 	ui := &UI{
-		s:    s,
-		keys: keymap.DefaultKeyMap(),
+		s:          s,
+		common:     common,
+		pages:      make([]tea.Model, 2), // selection & repo
+		activePage: 0,
+		state:      startState,
 	}
 	return ui
 }
 
 func (ui *UI) Init() tea.Cmd {
-	return nil
+	items := make([]string, 0)
+	cfg := ui.s.Config()
+	for _, r := range cfg.Repos {
+		items = append(items, r.Name)
+	}
+	for _, r := range cfg.Source.AllRepos() {
+		exists := false
+		for _, i := range items {
+			if i == r.Name() {
+				exists = true
+				break
+			}
+		}
+		if !exists {
+			items = append(items, r.Name())
+		}
+	}
+	ui.pages[0] = selection.New(ui.s, ui.common)
+	ui.pages[1] = selection.New(ui.s, ui.common)
+	ui.state = loadedState
+	return ui.pages[ui.activePage].Init()
 }
 
 func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	cmds := make([]tea.Cmd, 0)
 	switch msg := msg.(type) {
+	case tea.WindowSizeMsg:
+		for i, p := range ui.pages {
+			m, cmd := p.Update(msg)
+			ui.pages[i] = m
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	case tea.KeyMsg:
 		switch {
-		case key.Matches(msg, ui.keys.Quit):
+		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
+			if cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
+	default:
+		m, cmd := ui.pages[ui.activePage].Update(msg)
+		ui.pages[ui.activePage] = m
+		if cmd != nil {
+			cmds = append(cmds, cmd)
 		}
 	}
 	return ui, tea.Batch(cmds...)
 }
 
 func (ui *UI) View() string {
-	return ""
+	switch ui.state {
+	case startState:
+		return "Loading..."
+	case errorState:
+		return "Error"
+	case loadedState:
+		return ui.common.Styles.App.Render(ui.pages[ui.activePage].View())
+	default:
+		return "Unknown state :/ this is a bug!"
+	}
 }