selection.go

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