selection.go

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