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