selection.go

  1package selection
  2
  3import (
  4	"fmt"
  5	"strings"
  6
  7	"github.com/charmbracelet/bubbles/key"
  8	"github.com/charmbracelet/bubbles/list"
  9	tea "github.com/charmbracelet/bubbletea"
 10	"github.com/charmbracelet/lipgloss"
 11	appCfg "github.com/charmbracelet/soft-serve/config"
 12	"github.com/charmbracelet/soft-serve/ui/common"
 13	"github.com/charmbracelet/soft-serve/ui/components/code"
 14	"github.com/charmbracelet/soft-serve/ui/components/selector"
 15	"github.com/charmbracelet/soft-serve/ui/components/yankable"
 16	"github.com/charmbracelet/soft-serve/ui/session"
 17)
 18
 19type box int
 20
 21const (
 22	readmeBox box = iota
 23	selectorBox
 24)
 25
 26// Selection is the model for the selection screen/page.
 27type Selection struct {
 28	s         session.Session
 29	common    common.Common
 30	readme    *code.Code
 31	selector  *selector.Selector
 32	activeBox box
 33}
 34
 35// New creates a new selection model.
 36func New(s session.Session, common common.Common) *Selection {
 37	sel := &Selection{
 38		s:         s,
 39		common:    common,
 40		activeBox: selectorBox, // start with the selector focused
 41	}
 42	readme := code.New(common, "", "")
 43	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
 44	sel.readme = readme
 45	sel.selector = selector.New(common,
 46		[]selector.IdentifiableItem{},
 47		ItemDelegate{common.Styles, &sel.activeBox})
 48	return sel
 49}
 50
 51// SetSize implements common.Component.
 52func (s *Selection) SetSize(width, height int) {
 53	s.common.SetSize(width, height)
 54	sw := s.common.Styles.SelectorBox.GetWidth()
 55	wm := sw +
 56		s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
 57		s.common.Styles.ReadmeBox.GetHorizontalFrameSize() +
 58		// +1 to get wrapping to work.
 59		// This is needed because the readme box width has to be -1 from the
 60		// readme style in order for wrapping to not break.
 61		1
 62	hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
 63	s.readme.SetSize(width-wm, height-hm)
 64	s.selector.SetSize(sw, height)
 65}
 66
 67// ShortHelp implements help.KeyMap.
 68func (s *Selection) ShortHelp() []key.Binding {
 69	k := s.selector.KeyMap()
 70	kb := make([]key.Binding, 0)
 71	kb = append(kb,
 72		s.common.Keymap.UpDown,
 73		s.common.Keymap.Select,
 74	)
 75	if s.activeBox == selectorBox {
 76		kb = append(kb,
 77			k.Filter,
 78			k.ClearFilter,
 79		)
 80	}
 81	return kb
 82}
 83
 84// FullHelp implements help.KeyMap.
 85// TODO implement full help on ?
 86func (s *Selection) FullHelp() [][]key.Binding {
 87	k := s.selector.KeyMap()
 88	return [][]key.Binding{
 89		{
 90			k.CursorUp,
 91			k.CursorDown,
 92			k.NextPage,
 93			k.PrevPage,
 94			k.GoToStart,
 95			k.GoToEnd,
 96		},
 97		{
 98			k.Filter,
 99			k.ClearFilter,
100			k.CancelWhileFiltering,
101			k.AcceptWhileFiltering,
102			k.ShowFullHelp,
103			k.CloseFullHelp,
104		},
105		// Ignore the following keys:
106		// k.Quit,
107		// k.ForceQuit,
108	}
109}
110
111// Init implements tea.Model.
112func (s *Selection) Init() tea.Cmd {
113	session := s.s.Session()
114	environ := session.Environ()
115	termExists := false
116	// Add TERM using pty.Term if it's not already set.
117	for _, env := range environ {
118		if strings.HasPrefix(env, "TERM=") {
119			termExists = true
120			break
121		}
122	}
123	if !termExists {
124		pty, _, _ := session.Pty()
125		environ = append(environ, fmt.Sprintf("TERM=%s", pty.Term))
126	}
127	items := make([]list.Item, 0)
128	cfg := s.s.Config()
129	// TODO clean up this
130	yank := func(text string) *yankable.Yankable {
131		return yankable.New(
132			session,
133			environ,
134			lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
135			lipgloss.NewStyle().Foreground(lipgloss.Color("168")).SetString("Copied!"),
136			text,
137		)
138	}
139	// Put configured repos first
140	for _, r := range cfg.Repos {
141		items = append(items, Item{
142			name: r.Name,
143			repo: r.Repo,
144			desc: r.Note,
145			url:  yank(repoUrl(cfg, r.Repo)),
146		})
147	}
148	for _, r := range cfg.Source.AllRepos() {
149		exists := false
150		head, err := r.HEAD()
151		if err != nil {
152			return common.ErrorCmd(err)
153		}
154		lc, err := r.CommitsByPage(head, 1, 1)
155		if err != nil {
156			return common.ErrorCmd(err)
157		}
158		lastUpdate := lc[0].Committer.When
159		for _, item := range items {
160			item := item.(Item)
161			if item.repo == r.Name() {
162				exists = true
163				item.lastUpdate = lastUpdate
164				break
165			}
166		}
167		if !exists {
168			items = append(items, Item{
169				name:       r.Name(),
170				repo:       r.Name(),
171				desc:       "",
172				lastUpdate: lastUpdate,
173				url:        yank(repoUrl(cfg, r.Name())),
174			})
175		}
176	}
177	return tea.Batch(
178		s.selector.Init(),
179		s.selector.SetItems(items),
180	)
181}
182
183// Update implements tea.Model.
184func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
185	cmds := make([]tea.Cmd, 0)
186	switch msg := msg.(type) {
187	case tea.WindowSizeMsg:
188		r, cmd := s.readme.Update(msg)
189		s.readme = r.(*code.Code)
190		if cmd != nil {
191			cmds = append(cmds, cmd)
192		}
193		m, cmd := s.selector.Update(msg)
194		s.selector = m.(*selector.Selector)
195		if cmd != nil {
196			cmds = append(cmds, cmd)
197		}
198	case selector.ActiveMsg:
199		cmds = append(cmds, s.changeActive(msg))
200		// reset readme position when active item change
201		s.readme.GotoTop()
202	case tea.KeyMsg:
203		switch {
204		case key.Matches(msg, s.common.Keymap.Section):
205			s.activeBox = (s.activeBox + 1) % 2
206		}
207	}
208	switch s.activeBox {
209	case readmeBox:
210		r, cmd := s.readme.Update(msg)
211		s.readme = r.(*code.Code)
212		if cmd != nil {
213			cmds = append(cmds, cmd)
214		}
215	case selectorBox:
216		m, cmd := s.selector.Update(msg)
217		s.selector = m.(*selector.Selector)
218		if cmd != nil {
219			cmds = append(cmds, cmd)
220		}
221	}
222	return s, tea.Batch(cmds...)
223}
224
225// View implements tea.Model.
226func (s *Selection) View() string {
227	wm := s.common.Styles.SelectorBox.GetWidth() +
228		s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
229		s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
230	hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
231	rs := s.common.Styles.ReadmeBox.Copy().
232		Width(s.common.Width - wm).
233		Height(s.common.Height - hm)
234	if s.activeBox == readmeBox {
235		rs.BorderForeground(s.common.Styles.ActiveBorderColor)
236	}
237	readme := rs.Render(s.readme.View())
238	return lipgloss.JoinHorizontal(
239		lipgloss.Top,
240		readme,
241		s.selector.View(),
242	)
243}
244
245func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
246	cfg := s.s.Config()
247	r, err := cfg.Source.GetRepo(string(msg))
248	if err != nil {
249		return common.ErrorCmd(err)
250	}
251	rm, rp := r.Readme()
252	return s.readme.SetContent(rm, rp)
253}
254
255func repoUrl(cfg *appCfg.Config, name string) string {
256	port := ""
257	if cfg.Port != 22 {
258		port += fmt.Sprintf(":%d", cfg.Port)
259	}
260	return fmt.Sprintf("git clone ssh://%s/%s", cfg.Host+port, name)
261}