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