Detailed changes
@@ -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
@@ -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
+}
@@ -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())))
+}
@@ -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()
+}
@@ -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)
}
@@ -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
}
@@ -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
-}
@@ -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)
+}
@@ -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
+}
@@ -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: "─",
@@ -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!"
+ }
}