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