selection.go

  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	"github.com/charmbracelet/soft-serve/ui/common"
 11	"github.com/charmbracelet/soft-serve/ui/components/code"
 12	"github.com/charmbracelet/soft-serve/ui/components/selector"
 13	"github.com/charmbracelet/soft-serve/ui/components/yankable"
 14	"github.com/charmbracelet/soft-serve/ui/git"
 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		repo, err := cfg.Source.GetRepo(r.Repo)
168		if err != nil {
169			continue
170		}
171		items = append(items, Item{
172			repo: repo,
173			url:  yank(git.RepoURL(cfg.Host, cfg.Port, r.Repo)),
174		})
175	}
176	for _, r := range cfg.Source.AllRepos() {
177		exists := false
178		head, err := r.HEAD()
179		if err != nil {
180			return common.ErrorCmd(err)
181		}
182		lc, err := r.CommitsByPage(head, 1, 1)
183		if err != nil {
184			return common.ErrorCmd(err)
185		}
186		lastUpdate := lc[0].Committer.When
187		for _, item := range items {
188			item := item.(Item)
189			if item.repo.Repo() == r.Repo() {
190				exists = true
191				item.lastUpdate = lastUpdate
192				break
193			}
194		}
195		if !exists {
196			items = append(items, Item{
197				repo:       r,
198				lastUpdate: lastUpdate,
199				url:        yank(git.RepoURL(cfg.Host, cfg.Port, 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		case key.Matches(msg, s.common.KeyMap.Back):
233			cmds = append(cmds, s.selector.Init())
234		}
235	}
236	switch s.activeBox {
237	case readmeBox:
238		r, cmd := s.readme.Update(msg)
239		s.readme = r.(*code.Code)
240		if cmd != nil {
241			cmds = append(cmds, cmd)
242		}
243	case selectorBox:
244		m, cmd := s.selector.Update(msg)
245		s.selector = m.(*selector.Selector)
246		if cmd != nil {
247			cmds = append(cmds, cmd)
248		}
249	}
250	return s, tea.Batch(cmds...)
251}
252
253// View implements tea.Model.
254func (s *Selection) View() string {
255	wm := s.common.Styles.SelectorBox.GetWidth() +
256		s.common.Styles.SelectorBox.GetHorizontalFrameSize() +
257		s.common.Styles.ReadmeBox.GetHorizontalFrameSize()
258	hm := s.common.Styles.ReadmeBox.GetVerticalFrameSize()
259	rs := s.common.Styles.ReadmeBox.Copy().
260		Width(s.common.Width - wm).
261		Height(s.common.Height - hm)
262	if s.activeBox == readmeBox {
263		rs.BorderForeground(s.common.Styles.ActiveBorderColor)
264	}
265	readme := rs.Render(s.readme.View())
266	return lipgloss.JoinHorizontal(
267		lipgloss.Top,
268		readme,
269		s.selector.View(),
270	)
271}
272
273func (s *Selection) changeActive(msg selector.ActiveMsg) tea.Cmd {
274	cfg := s.s.Config()
275	r, err := cfg.Source.GetRepo(msg.ID())
276	if err != nil {
277		return common.ErrorCmd(err)
278	}
279	rm, rp := r.Readme()
280	return s.readme.SetContent(rm, rp)
281}