1package selection
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"strings"
  7
  8	"github.com/charmbracelet/bubbles/key"
  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.
 91func (s *Selection) FullHelp() [][]key.Binding {
 92	switch s.activeBox {
 93	case readmeBox:
 94		k := s.readme.KeyMap
 95		return [][]key.Binding{
 96			{
 97				k.PageDown,
 98				k.PageUp,
 99			},
100			{
101				k.HalfPageDown,
102				k.HalfPageUp,
103			},
104			{
105				k.Down,
106				k.Up,
107			},
108		}
109	case selectorBox:
110		k := s.selector.KeyMap
111		return [][]key.Binding{
112			{
113				s.common.KeyMap.Select,
114			},
115			{
116				k.CursorUp,
117				k.CursorDown,
118			},
119			{
120				k.NextPage,
121				k.PrevPage,
122			},
123			{
124				k.GoToStart,
125				k.GoToEnd,
126			},
127			{
128				k.Filter,
129				k.ClearFilter,
130				k.CancelWhileFiltering,
131				k.AcceptWhileFiltering,
132			},
133		}
134	}
135	return [][]key.Binding{}
136}
137
138// Init implements tea.Model.
139func (s *Selection) Init() tea.Cmd {
140	session := s.s.Session()
141	environ := session.Environ()
142	termExists := false
143	// Add TERM using pty.Term if it's not already set.
144	for _, env := range environ {
145		if strings.HasPrefix(env, "TERM=") {
146			termExists = true
147			break
148		}
149	}
150	if !termExists {
151		pty, _, _ := session.Pty()
152		environ = append(environ, fmt.Sprintf("TERM=%s", pty.Term))
153	}
154	items := make([]selector.IdentifiableItem, 0)
155	cfg := s.s.Config()
156	// TODO clean up this and move style to its own var.
157	yank := func(text string) *yankable.Yankable {
158		return yankable.New(
159			session,
160			environ,
161			lipgloss.NewStyle().Foreground(lipgloss.Color("168")),
162			lipgloss.NewStyle().Foreground(lipgloss.Color("168")).SetString("Copied!"),
163			text,
164		)
165	}
166	// Put configured repos first
167	for _, r := range cfg.Repos {
168		items = append(items, Item{
169			name: r.Name,
170			repo: r.Repo,
171			desc: r.Note,
172			url:  yank(repoUrl(cfg, r.Repo)),
173		})
174	}
175	for _, r := range cfg.Source.AllRepos() {
176		exists := false
177		head, err := r.HEAD()
178		if err != nil {
179			return common.ErrorCmd(err)
180		}
181		lc, err := r.CommitsByPage(head, 1, 1)
182		if err != nil {
183			return common.ErrorCmd(err)
184		}
185		lastUpdate := lc[0].Committer.When
186		for _, item := range items {
187			item := item.(Item)
188			if item.repo == r.Name() {
189				exists = true
190				item.lastUpdate = lastUpdate
191				break
192			}
193		}
194		if !exists {
195			items = append(items, Item{
196				name:       r.Name(),
197				repo:       r.Name(),
198				desc:       "",
199				lastUpdate: lastUpdate,
200				url:        yank(repoUrl(cfg, r.Name())),
201			})
202		}
203	}
204	return tea.Batch(
205		s.selector.Init(),
206		s.selector.SetItems(items),
207	)
208}
209
210// Update implements tea.Model.
211func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
212	cmds := make([]tea.Cmd, 0)
213	switch msg := msg.(type) {
214	case tea.WindowSizeMsg:
215		r, cmd := s.readme.Update(msg)
216		s.readme = r.(*code.Code)
217		if cmd != nil {
218			cmds = append(cmds, cmd)
219		}
220		m, cmd := s.selector.Update(msg)
221		s.selector = m.(*selector.Selector)
222		if cmd != nil {
223			cmds = append(cmds, cmd)
224		}
225	case selector.ActiveMsg:
226		cmds = append(cmds, s.changeActive(msg))
227		// reset readme position when active item change
228		s.readme.GotoTop()
229	case tea.KeyMsg:
230		switch {
231		case key.Matches(msg, s.common.KeyMap.Section):
232			s.activeBox = (s.activeBox + 1) % 2
233		case msg.String() == "a":
234			cmds = append(cmds, common.ErrorCmd(errors.New("not implemented")))
235		}
236	}
237	switch s.activeBox {
238	case readmeBox:
239		r, cmd := s.readme.Update(msg)
240		s.readme = r.(*code.Code)
241		if cmd != nil {
242			cmds = append(cmds, cmd)
243		}
244	case selectorBox:
245		m, cmd := s.selector.Update(msg)
246		s.selector = m.(*selector.Selector)
247		if cmd != nil {
248			cmds = append(cmds, cmd)
249		}
250	}
251	return s, tea.Batch(cmds...)
252}
253
254// View implements tea.Model.
255func (s *Selection) View() string {
256	wm := s.common.Styles.SelectorBox.GetWidth() +
257		s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
258		s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
259	hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
260	rs := s.common.Styles.ReadmeBox.Copy().
261		Width(s.common.Width - wm).
262		Height(s.common.Height - hm)
263	if s.activeBox == readmeBox {
264		rs.BorderForeground(s.common.Styles.ActiveBorderColor)
265	}
266	readme := rs.Render(s.readme.View())
267	return lipgloss.JoinHorizontal(
268		lipgloss.Top,
269		readme,
270		s.selector.View(),
271	)
272}
273
274func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
275	cfg := s.s.Config()
276	r, err := cfg.Source.GetRepo(msg.ID())
277	if err != nil {
278		return common.ErrorCmd(err)
279	}
280	rm, rp := r.Readme()
281	return s.readme.SetContent(rm, rp)
282}
283
284func repoUrl(cfg *appCfg.Config, name string) string {
285	port := ""
286	if cfg.Port != 22 {
287		port += fmt.Sprintf(":%d", cfg.Port)
288	}
289	return fmt.Sprintf("git clone ssh://%s/%s", cfg.Host+port, name)
290}