1package selection
  2
  3import (
  4	"fmt"
  5	"sort"
  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	"github.com/charmbracelet/log"
 12	"github.com/charmbracelet/soft-serve/server/backend"
 13	"github.com/charmbracelet/soft-serve/ui/common"
 14	"github.com/charmbracelet/soft-serve/ui/components/code"
 15	"github.com/charmbracelet/soft-serve/ui/components/selector"
 16	"github.com/charmbracelet/soft-serve/ui/components/tabs"
 17)
 18
 19const (
 20	defaultNoContent = "No readme found.\n\nCreate a `.soft-serve` repository and add a `README.md` file to display readme."
 21)
 22
 23var (
 24	logger = log.WithPrefix("ui.selection")
 25)
 26
 27type pane int
 28
 29const (
 30	selectorPane pane = iota
 31	readmePane
 32	lastPane
 33)
 34
 35func (p pane) String() string {
 36	return []string{
 37		"Repositories",
 38		"About",
 39	}[p]
 40}
 41
 42// Selection is the model for the selection screen/page.
 43type Selection struct {
 44	common       common.Common
 45	readme       *code.Code
 46	readmeHeight int
 47	selector     *selector.Selector
 48	activePane   pane
 49	tabs         *tabs.Tabs
 50}
 51
 52// New creates a new selection model.
 53func New(c common.Common) *Selection {
 54	ts := make([]string, lastPane)
 55	for i, b := range []pane{selectorPane, readmePane} {
 56		ts[i] = b.String()
 57	}
 58	t := tabs.New(c, ts)
 59	t.TabSeparator = lipgloss.NewStyle()
 60	t.TabInactive = c.Styles.TopLevelNormalTab.Copy()
 61	t.TabActive = c.Styles.TopLevelActiveTab.Copy()
 62	t.TabDot = c.Styles.TopLevelActiveTabDot.Copy()
 63	t.UseDot = true
 64	sel := &Selection{
 65		common:     c,
 66		activePane: selectorPane, // start with the selector focused
 67		tabs:       t,
 68	}
 69	readme := code.New(c, "", "")
 70	readme.NoContentStyle = c.Styles.NoContent.Copy().
 71		SetString(defaultNoContent)
 72	selector := selector.New(c,
 73		[]selector.IdentifiableItem{},
 74		NewItemDelegate(&c, &sel.activePane))
 75	selector.SetShowTitle(false)
 76	selector.SetShowHelp(false)
 77	selector.SetShowStatusBar(false)
 78	selector.DisableQuitKeybindings()
 79	sel.selector = selector
 80	sel.readme = readme
 81	return sel
 82}
 83
 84func (s *Selection) getMargins() (wm, hm int) {
 85	wm = 0
 86	hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
 87		s.common.Styles.Tabs.GetHeight()
 88	if s.activePane == selectorPane && s.IsFiltering() {
 89		// hide tabs when filtering
 90		hm = 0
 91	}
 92	return
 93}
 94
 95// FilterState returns the current filter state.
 96func (s *Selection) FilterState() list.FilterState {
 97	return s.selector.FilterState()
 98}
 99
100// SetSize implements common.Component.
101func (s *Selection) SetSize(width, height int) {
102	s.common.SetSize(width, height)
103	wm, hm := s.getMargins()
104	s.tabs.SetSize(width, height-hm)
105	s.selector.SetSize(width-wm, height-hm)
106	s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line
107}
108
109// IsFiltering returns true if the selector is currently filtering.
110func (s *Selection) IsFiltering() bool {
111	return s.FilterState() == list.Filtering
112}
113
114// ShortHelp implements help.KeyMap.
115func (s *Selection) ShortHelp() []key.Binding {
116	k := s.selector.KeyMap
117	kb := make([]key.Binding, 0)
118	kb = append(kb,
119		s.common.KeyMap.UpDown,
120		s.common.KeyMap.Section,
121	)
122	if s.activePane == selectorPane {
123		copyKey := s.common.KeyMap.Copy
124		copyKey.SetHelp("c", "copy command")
125		kb = append(kb,
126			s.common.KeyMap.Select,
127			k.Filter,
128			k.ClearFilter,
129			copyKey,
130		)
131	}
132	return kb
133}
134
135// FullHelp implements help.KeyMap.
136func (s *Selection) FullHelp() [][]key.Binding {
137	b := [][]key.Binding{
138		{
139			s.common.KeyMap.Section,
140		},
141	}
142	switch s.activePane {
143	case readmePane:
144		k := s.readme.KeyMap
145		b = append(b, []key.Binding{
146			k.PageDown,
147			k.PageUp,
148		})
149		b = append(b, []key.Binding{
150			k.HalfPageDown,
151			k.HalfPageUp,
152		})
153		b = append(b, []key.Binding{
154			k.Down,
155			k.Up,
156		})
157	case selectorPane:
158		copyKey := s.common.KeyMap.Copy
159		copyKey.SetHelp("c", "copy command")
160		k := s.selector.KeyMap
161		if !s.IsFiltering() {
162			b[0] = append(b[0],
163				s.common.KeyMap.Select,
164				copyKey,
165			)
166		}
167		b = append(b, []key.Binding{
168			k.CursorUp,
169			k.CursorDown,
170		})
171		b = append(b, []key.Binding{
172			k.NextPage,
173			k.PrevPage,
174			k.GoToStart,
175			k.GoToEnd,
176		})
177		b = append(b, []key.Binding{
178			k.Filter,
179			k.ClearFilter,
180			k.CancelWhileFiltering,
181			k.AcceptWhileFiltering,
182		})
183	}
184	return b
185}
186
187// Init implements tea.Model.
188func (s *Selection) Init() tea.Cmd {
189	var readmeCmd tea.Cmd
190	cfg := s.common.Config()
191	if cfg == nil {
192		return nil
193	}
194
195	pk := s.common.PublicKey()
196	if pk == nil && !cfg.Backend.AllowKeyless() {
197		return nil
198	}
199
200	repos, err := cfg.Backend.Repositories()
201	if err != nil {
202		return common.ErrorCmd(err)
203	}
204	sortedItems := make(Items, 0)
205	for _, r := range repos {
206		if r.Name() == ".soft-serve" {
207			readme, path, err := backend.Readme(r)
208			if err != nil {
209				continue
210			}
211
212			readmeCmd = s.readme.SetContent(readme, path)
213		}
214
215		if r.IsHidden() {
216			continue
217		}
218		al := cfg.Backend.AccessLevelByPublicKey(r.Name(), pk)
219		if al >= backend.ReadOnlyAccess {
220			item, err := NewItem(r, cfg)
221			if err != nil {
222				logger.Debugf("ui: failed to create item for %s: %v", r.Name(), err)
223				continue
224			}
225			sortedItems = append(sortedItems, item)
226		}
227	}
228	sort.Sort(sortedItems)
229	items := make([]selector.IdentifiableItem, len(sortedItems))
230	for i, it := range sortedItems {
231		items[i] = it
232	}
233	return tea.Batch(
234		s.selector.Init(),
235		s.selector.SetItems(items),
236		readmeCmd,
237	)
238}
239
240// Update implements tea.Model.
241func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
242	cmds := make([]tea.Cmd, 0)
243	switch msg := msg.(type) {
244	case tea.WindowSizeMsg:
245		r, cmd := s.readme.Update(msg)
246		s.readme = r.(*code.Code)
247		if cmd != nil {
248			cmds = append(cmds, cmd)
249		}
250		m, cmd := s.selector.Update(msg)
251		s.selector = m.(*selector.Selector)
252		if cmd != nil {
253			cmds = append(cmds, cmd)
254		}
255	case tea.KeyMsg, tea.MouseMsg:
256		switch msg := msg.(type) {
257		case tea.KeyMsg:
258			switch {
259			case key.Matches(msg, s.common.KeyMap.Back):
260				cmds = append(cmds, s.selector.Init())
261			}
262		}
263		t, cmd := s.tabs.Update(msg)
264		s.tabs = t.(*tabs.Tabs)
265		if cmd != nil {
266			cmds = append(cmds, cmd)
267		}
268	case tabs.ActiveTabMsg:
269		s.activePane = pane(msg)
270	}
271	switch s.activePane {
272	case readmePane:
273		r, cmd := s.readme.Update(msg)
274		s.readme = r.(*code.Code)
275		if cmd != nil {
276			cmds = append(cmds, cmd)
277		}
278	case selectorPane:
279		m, cmd := s.selector.Update(msg)
280		s.selector = m.(*selector.Selector)
281		if cmd != nil {
282			cmds = append(cmds, cmd)
283		}
284	}
285	return s, tea.Batch(cmds...)
286}
287
288// View implements tea.Model.
289func (s *Selection) View() string {
290	var view string
291	wm, hm := s.getMargins()
292	switch s.activePane {
293	case selectorPane:
294		ss := lipgloss.NewStyle().
295			Width(s.common.Width - wm).
296			Height(s.common.Height - hm)
297		view = ss.Render(s.selector.View())
298	case readmePane:
299		rs := lipgloss.NewStyle().
300			Height(s.common.Height - hm)
301		status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
302		readmeStatus := lipgloss.NewStyle().
303			Align(lipgloss.Right).
304			Width(s.common.Width - wm).
305			Foreground(s.common.Styles.InactiveBorderColor).
306			Render(status)
307		view = rs.Render(lipgloss.JoinVertical(lipgloss.Left,
308			s.readme.View(),
309			readmeStatus,
310		))
311	}
312	if s.activePane != selectorPane || s.FilterState() != list.Filtering {
313		tabs := s.common.Styles.Tabs.Render(s.tabs.View())
314		view = lipgloss.JoinVertical(lipgloss.Left,
315			tabs,
316			view,
317		)
318	}
319	return lipgloss.JoinVertical(
320		lipgloss.Left,
321		view,
322	)
323}