diff --git a/server/session.go b/server/session.go index 1a040fb181f600d1a66a4ac9da133e598fc26da5..92f7273007ceaecdcf6da9ee52ed0c5ab6465d4a 100644 --- a/server/session.go +++ b/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 diff --git a/ui/common/common.go b/ui/common/common.go new file mode 100644 index 0000000000000000000000000000000000000000..9a17f389b4a45d302a1896368f912173b0b4a435 --- /dev/null +++ b/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 +} diff --git a/ui/components/selector/item.go b/ui/components/selector/item.go new file mode 100644 index 0000000000000000000000000000000000000000..ce2f59a4c05c1c543a7f7b7de5e0b6825727814a --- /dev/null +++ b/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()))) +} diff --git a/ui/components/selector/selector.go b/ui/components/selector/selector.go new file mode 100644 index 0000000000000000000000000000000000000000..6971d06559c3818db3c26c18765bb8a596b91843 --- /dev/null +++ b/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() +} diff --git a/ui/components/yankable/yankable.go b/ui/components/yankable/yankable.go index 6c3190191136e7bd870aa1d0932f287a43886b9a..862ebb0342d82f33154a80bd742cce594c8a48d6 100644 --- a/ui/components/yankable/yankable.go +++ b/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) } diff --git a/ui/keymap/keymap.go b/ui/keymap/keymap.go index 859ec32192df0073051dc32ff629db2b674a03d7..684080e393926cb7176cb9cb014e6613f3667bf7 100644 --- a/ui/keymap/keymap.go +++ b/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 } diff --git a/ui/pages/selection/bubble.go b/ui/pages/selection/bubble.go deleted file mode 100644 index 8be37dcf3b2a1e78974bc22c7c1caa830f5a8390..0000000000000000000000000000000000000000 --- a/ui/pages/selection/bubble.go +++ /dev/null @@ -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 -} diff --git a/ui/pages/selection/selection.go b/ui/pages/selection/selection.go new file mode 100644 index 0000000000000000000000000000000000000000..ddcf5b9ca34d4de0c0b8ef9ae4e30179f9d06686 --- /dev/null +++ b/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) +} diff --git a/ui/session/session.go b/ui/session/session.go new file mode 100644 index 0000000000000000000000000000000000000000..9342cb93befe027cad71dbecdaaad26cd72c64b7 --- /dev/null +++ b/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 +} diff --git a/ui/styles/styles.go b/ui/styles/styles.go index 8ad308c42ffceb01b5c1e7ca9fda7603d53e9eb2..5a5d4951f87f1354ef90c1fb995cd5970db77b45 100644 --- a/ui/styles/styles.go +++ b/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: "─", diff --git a/ui/ui.go b/ui/ui.go index 018a47cb1a44c3e31e404885f2bbd8dac643e96d..60243b327a49d8919f92a5e917ae49a7f3cb022c 100644 --- a/ui/ui.go +++ b/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!" + } }