selection.go

  1package selection
  2
  3import (
  4	"fmt"
  5	"sort"
  6
  7	"github.com/charmbracelet/bubbles/v2/key"
  8	"github.com/charmbracelet/bubbles/v2/list"
  9	tea "github.com/charmbracelet/bubbletea/v2"
 10	lipgloss "github.com/charmbracelet/lipgloss/v2"
 11	"github.com/charmbracelet/soft-serve/pkg/access"
 12	"github.com/charmbracelet/soft-serve/pkg/backend"
 13	"github.com/charmbracelet/soft-serve/pkg/ui/common"
 14	"github.com/charmbracelet/soft-serve/pkg/ui/components/code"
 15	"github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
 16	"github.com/charmbracelet/soft-serve/pkg/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
 23type pane int
 24
 25const (
 26	selectorPane pane = iota
 27	readmePane
 28	lastPane
 29)
 30
 31func (p pane) String() string {
 32	return []string{
 33		"Repositories",
 34		"About",
 35	}[p]
 36}
 37
 38// Selection is the model for the selection screen/page.
 39type Selection struct {
 40	common     common.Common
 41	readme     *code.Code
 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
 56	t.TabActive = c.Styles.TopLevelActiveTab
 57	t.TabDot = c.Styles.TopLevelActiveTabDot
 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.UseGlamour = true
 66	readme.NoContentStyle = c.Styles.NoContent.
 67		SetString(defaultNoContent)
 68	selector := selector.New(c,
 69		[]selector.IdentifiableItem{},
 70		NewItemDelegate(&c, &sel.activePane))
 71	selector.SetShowTitle(false)
 72	selector.SetShowHelp(false)
 73	selector.SetShowStatusBar(false)
 74	selector.DisableQuitKeybindings()
 75	sel.selector = selector
 76	sel.readme = readme
 77	return sel
 78}
 79
 80func (s *Selection) getMargins() (wm, hm int) {
 81	wm = 0
 82	hm = s.common.Styles.Tabs.GetVerticalFrameSize() +
 83		s.common.Styles.Tabs.GetHeight()
 84	if s.activePane == selectorPane && s.IsFiltering() {
 85		// hide tabs when filtering
 86		hm = 0
 87	}
 88	return
 89}
 90
 91// FilterState returns the current filter state.
 92func (s *Selection) FilterState() list.FilterState {
 93	return s.selector.FilterState()
 94}
 95
 96// SetSize implements common.Component.
 97func (s *Selection) SetSize(width, height int) {
 98	s.common.SetSize(width, height)
 99	wm, hm := s.getMargins()
100	s.tabs.SetSize(width, height-hm)
101	s.selector.SetSize(width-wm, height-hm)
102	s.readme.SetSize(width-wm, height-hm-1) // -1 for readme status line
103}
104
105// IsFiltering returns true if the selector is currently filtering.
106func (s *Selection) IsFiltering() bool {
107	return s.FilterState() == list.Filtering
108}
109
110// ShortHelp implements help.KeyMap.
111func (s *Selection) ShortHelp() []key.Binding {
112	k := s.selector.KeyMap
113	kb := make([]key.Binding, 0)
114	kb = append(kb,
115		s.common.KeyMap.UpDown,
116		s.common.KeyMap.Section,
117	)
118	if s.activePane == selectorPane {
119		copyKey := s.common.KeyMap.Copy
120		copyKey.SetHelp("c", "copy command")
121		kb = append(kb,
122			s.common.KeyMap.Select,
123			k.Filter,
124			k.ClearFilter,
125			copyKey,
126		)
127	}
128	return kb
129}
130
131// FullHelp implements help.KeyMap.
132func (s *Selection) FullHelp() [][]key.Binding {
133	b := [][]key.Binding{
134		{
135			s.common.KeyMap.Section,
136		},
137	}
138	switch s.activePane {
139	case readmePane:
140		k := s.readme.KeyMap
141		b = append(b, []key.Binding{
142			k.PageDown,
143			k.PageUp,
144		})
145		b = append(b, []key.Binding{
146			k.HalfPageDown,
147			k.HalfPageUp,
148		})
149		b = append(b, []key.Binding{
150			k.Down,
151			k.Up,
152		})
153	case selectorPane:
154		copyKey := s.common.KeyMap.Copy
155		copyKey.SetHelp("c", "copy command")
156		k := s.selector.KeyMap
157		if !s.IsFiltering() {
158			b[0] = append(b[0],
159				s.common.KeyMap.Select,
160				copyKey,
161			)
162		}
163		b = append(b, []key.Binding{
164			k.CursorUp,
165			k.CursorDown,
166		})
167		b = append(b, []key.Binding{
168			k.NextPage,
169			k.PrevPage,
170			k.GoToStart,
171			k.GoToEnd,
172		})
173		b = append(b, []key.Binding{
174			k.Filter,
175			k.ClearFilter,
176			k.CancelWhileFiltering,
177			k.AcceptWhileFiltering,
178		})
179	case lastPane:
180		// lastPane is not a real pane, used for bounds checking
181	}
182	return b
183}
184
185// Init implements tea.Model.
186func (s *Selection) Init() tea.Cmd {
187	var readmeCmd tea.Cmd
188	cfg := s.common.Config()
189	if cfg == nil {
190		return nil
191	}
192
193	ctx := s.common.Context()
194	be := s.common.Backend()
195	pk := s.common.PublicKey()
196	if pk == nil && !be.AllowKeyless(ctx) {
197		return nil
198	}
199
200	repos, err := be.Repositories(ctx)
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, nil)
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 := be.AccessLevelByPublicKey(ctx, r.Name(), pk)
219		if al >= access.ReadOnlyAccess {
220			item, err := NewItem(s.common, r)
221			if err != nil {
222				s.common.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.KeyPressMsg, tea.MouseMsg:
256		switch msg := msg.(type) {
257		case tea.KeyPressMsg:
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	case lastPane:
285		// lastPane is not a real pane, no updates needed
286	}
287	return s, tea.Batch(cmds...)
288}
289
290// View implements tea.Model.
291func (s *Selection) View() string {
292	var view string
293	wm, hm := s.getMargins()
294	switch s.activePane {
295	case selectorPane:
296		ss := lipgloss.NewStyle().
297			Width(s.common.Width - wm).
298			Height(s.common.Height - hm)
299		view = ss.Render(s.selector.View())
300	case readmePane:
301		rs := lipgloss.NewStyle().
302			Height(s.common.Height - hm)
303		status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
304		readmeStatus := lipgloss.NewStyle().
305			Align(lipgloss.Right).
306			Width(s.common.Width - wm).
307			Foreground(s.common.Styles.InactiveBorderColor).
308			Render(status)
309		view = rs.Render(lipgloss.JoinVertical(lipgloss.Left,
310			s.readme.View(),
311			readmeStatus,
312		))
313	case lastPane:
314		// lastPane is not a real pane, no view
315	}
316	if s.activePane != selectorPane || s.FilterState() != list.Filtering {
317		tabs := s.common.Styles.Tabs.Render(s.tabs.View())
318		view = lipgloss.JoinVertical(lipgloss.Left,
319			tabs,
320			view,
321		)
322	}
323	return lipgloss.JoinVertical(
324		lipgloss.Left,
325		view,
326	)
327}