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