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	"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/tabs"
 15	"github.com/charmbracelet/soft-serve/ui/git"
 16	wgit "github.com/charmbracelet/wish/git"
 17	"github.com/gliderlabs/ssh"
 18)
 19
 20type box int
 21
 22const (
 23	selectorBox box = iota
 24	readmeBox
 25	lastBox
 26)
 27
 28func (b box) String() string {
 29	return []string{
 30		"Repositories",
 31		"About",
 32	}[b]
 33}
 34
 35// Selection is the model for the selection screen/page.
 36type Selection struct {
 37	cfg          *config.Config
 38	pk           ssh.PublicKey
 39	common       common.Common
 40	readme       *code.Code
 41	readmeHeight int
 42	selector     *selector.Selector
 43	activeBox    box
 44	tabs         *tabs.Tabs
 45}
 46
 47// New creates a new selection model.
 48func New(cfg *config.Config, pk ssh.PublicKey, common common.Common) *Selection {
 49	ts := make([]string, lastBox)
 50	for i, b := range []box{selectorBox, readmeBox} {
 51		ts[i] = b.String()
 52	}
 53	t := tabs.New(common, ts)
 54	t.TabSeparator = lipgloss.NewStyle()
 55	t.TabInactive = lipgloss.NewStyle().
 56		Bold(true).
 57		UnsetBackground().
 58		Foreground(common.Styles.InactiveBorderColor).
 59		Padding(0, 1)
 60	t.TabActive = t.TabInactive.Copy().
 61		Background(lipgloss.Color("62")).
 62		Foreground(lipgloss.Color("230"))
 63	sel := &Selection{
 64		cfg:       cfg,
 65		pk:        pk,
 66		common:    common,
 67		activeBox: selectorBox, // start with the selector focused
 68		tabs:      t,
 69	}
 70	readme := code.New(common, "", "")
 71	readme.NoContentStyle = readme.NoContentStyle.SetString("No readme found.")
 72	selector := selector.New(common,
 73		[]selector.IdentifiableItem{},
 74		ItemDelegate{&common, &sel.activeBox})
 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		2 // tabs margin see View()
 89	switch s.activeBox {
 90	case selectorBox:
 91	case readmeBox:
 92		hm += 1 // readme statusbar
 93	}
 94	return
 95}
 96
 97// SetSize implements common.Component.
 98func (s *Selection) SetSize(width, height int) {
 99	s.common.SetSize(width, height)
100	wm, hm := s.getMargins()
101	s.tabs.SetSize(width, height-hm)
102	s.selector.SetSize(width-wm, height-hm)
103	s.readme.SetSize(width-wm, height-hm)
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.activeBox == selectorBox {
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	switch s.activeBox {
130	case readmeBox:
131		k := s.readme.KeyMap
132		return [][]key.Binding{
133			{
134				k.PageDown,
135				k.PageUp,
136			},
137			{
138				k.HalfPageDown,
139				k.HalfPageUp,
140			},
141			{
142				k.Down,
143				k.Up,
144			},
145		}
146	case selectorBox:
147		copyKey := s.common.KeyMap.Copy
148		copyKey.SetHelp("c", "copy command")
149		k := s.selector.KeyMap
150		return [][]key.Binding{
151			{
152				s.common.KeyMap.Select,
153				copyKey,
154				k.CursorUp,
155				k.CursorDown,
156			},
157			{
158				k.NextPage,
159				k.PrevPage,
160				k.GoToStart,
161				k.GoToEnd,
162			},
163			{
164				k.Filter,
165				k.ClearFilter,
166				k.CancelWhileFiltering,
167				k.AcceptWhileFiltering,
168			},
169		}
170	}
171	return [][]key.Binding{}
172}
173
174// Init implements tea.Model.
175func (s *Selection) Init() tea.Cmd {
176	var readmeCmd tea.Cmd
177	items := make([]selector.IdentifiableItem, 0)
178	cfg := s.cfg
179	pk := s.pk
180	// Put configured repos first
181	for _, r := range cfg.Repos {
182		acc := cfg.AuthRepo(r.Repo, pk)
183		if r.Private && acc < wgit.ReadOnlyAccess {
184			continue
185		}
186		repo, err := cfg.Source.GetRepo(r.Repo)
187		if err != nil {
188			continue
189		}
190		items = append(items, Item{
191			repo: repo,
192			cmd:  git.RepoURL(cfg.Host, cfg.Port, r.Repo),
193		})
194	}
195	for _, r := range cfg.Source.AllRepos() {
196		if r.Repo() == "config" {
197			rm, rp := r.Readme()
198			s.readmeHeight = strings.Count(rm, "\n")
199			readmeCmd = s.readme.SetContent(rm, rp)
200		}
201		acc := cfg.AuthRepo(r.Repo(), pk)
202		if r.IsPrivate() && acc < wgit.ReadOnlyAccess {
203			continue
204		}
205		exists := false
206		lc, err := r.Commit("HEAD")
207		if err != nil {
208			return common.ErrorCmd(err)
209		}
210		lastUpdate := lc.Committer.When
211		if lastUpdate.IsZero() {
212			lastUpdate = lc.Author.When
213		}
214		for i, item := range items {
215			item := item.(Item)
216			if item.repo.Repo() == r.Repo() {
217				exists = true
218				item.lastUpdate = lastUpdate
219				items[i] = item
220				break
221			}
222		}
223		if !exists {
224			items = append(items, Item{
225				repo:       r,
226				lastUpdate: lastUpdate,
227				cmd:        git.RepoURL(cfg.Host, cfg.Port, r.Name()),
228			})
229		}
230	}
231	return tea.Batch(
232		s.selector.Init(),
233		s.selector.SetItems(items),
234		readmeCmd,
235	)
236}
237
238// Update implements tea.Model.
239func (s *Selection) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
240	cmds := make([]tea.Cmd, 0)
241	switch msg := msg.(type) {
242	case tea.WindowSizeMsg:
243		r, cmd := s.readme.Update(msg)
244		s.readme = r.(*code.Code)
245		if cmd != nil {
246			cmds = append(cmds, cmd)
247		}
248		m, cmd := s.selector.Update(msg)
249		s.selector = m.(*selector.Selector)
250		if cmd != nil {
251			cmds = append(cmds, cmd)
252		}
253	case tea.KeyMsg, tea.MouseMsg:
254		switch msg := msg.(type) {
255		case tea.KeyMsg:
256			switch {
257			case key.Matches(msg, s.common.KeyMap.Back):
258				cmds = append(cmds, s.selector.Init())
259			}
260		}
261		t, cmd := s.tabs.Update(msg)
262		s.tabs = t.(*tabs.Tabs)
263		if cmd != nil {
264			cmds = append(cmds, cmd)
265		}
266	case tabs.ActiveTabMsg:
267		s.activeBox = box(msg)
268	}
269	switch s.activeBox {
270	case readmeBox:
271		r, cmd := s.readme.Update(msg)
272		s.readme = r.(*code.Code)
273		if cmd != nil {
274			cmds = append(cmds, cmd)
275		}
276	case selectorBox:
277		m, cmd := s.selector.Update(msg)
278		s.selector = m.(*selector.Selector)
279		if cmd != nil {
280			cmds = append(cmds, cmd)
281		}
282	}
283	return s, tea.Batch(cmds...)
284}
285
286// View implements tea.Model.
287func (s *Selection) View() string {
288	var view string
289	wm, hm := s.getMargins()
290	hm++ // tabs margin
291	switch s.activeBox {
292	case selectorBox:
293		ss := lipgloss.NewStyle().
294			Width(s.common.Width - wm).
295			Height(s.common.Height - hm)
296		view = ss.Render(s.selector.View())
297	case readmeBox:
298		rs := lipgloss.NewStyle().
299			Height(s.common.Height - hm)
300		status := fmt.Sprintf("☰ %.f%%", s.readme.ScrollPercent()*100)
301		readmeStatus := lipgloss.NewStyle().
302			Align(lipgloss.Right).
303			Width(s.common.Width - wm).
304			Foreground(s.common.Styles.InactiveBorderColor).
305			Render(status)
306		view = rs.Render(lipgloss.JoinVertical(lipgloss.Top,
307			s.readme.View(),
308			readmeStatus,
309		))
310	}
311	ts := s.common.Styles.Tabs.Copy().
312		MarginBottom(1)
313	return lipgloss.JoinVertical(lipgloss.Top,
314		ts.Render(s.tabs.View()),
315		view,
316	)
317}