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