1package ui
  2
  3import (
  4	"errors"
  5
  6	"github.com/charmbracelet/bubbles/key"
  7	"github.com/charmbracelet/bubbles/list"
  8	tea "github.com/charmbracelet/bubbletea"
  9	"github.com/charmbracelet/lipgloss"
 10	"github.com/charmbracelet/log"
 11	"github.com/charmbracelet/soft-serve/server/backend"
 12	"github.com/charmbracelet/soft-serve/ui/common"
 13	"github.com/charmbracelet/soft-serve/ui/components/footer"
 14	"github.com/charmbracelet/soft-serve/ui/components/header"
 15	"github.com/charmbracelet/soft-serve/ui/components/selector"
 16	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 17	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 18)
 19
 20var (
 21	logger = log.WithPrefix("ui")
 22)
 23
 24type page int
 25
 26const (
 27	selectionPage page = iota
 28	repoPage
 29)
 30
 31type sessionState int
 32
 33const (
 34	loadingState sessionState = iota
 35	errorState
 36	readyState
 37)
 38
 39// UI is the main UI model.
 40type UI struct {
 41	serverName  string
 42	initialRepo string
 43	common      common.Common
 44	pages       []common.Component
 45	activePage  page
 46	state       sessionState
 47	header      *header.Header
 48	footer      *footer.Footer
 49	showFooter  bool
 50	error       error
 51}
 52
 53// New returns a new UI model.
 54func New(c common.Common, initialRepo string) *UI {
 55	serverName := c.Config().Name
 56	h := header.New(c, serverName)
 57	ui := &UI{
 58		serverName:  serverName,
 59		common:      c,
 60		pages:       make([]common.Component, 2), // selection & repo
 61		activePage:  selectionPage,
 62		state:       loadingState,
 63		header:      h,
 64		initialRepo: initialRepo,
 65		showFooter:  true,
 66	}
 67	ui.footer = footer.New(c, ui)
 68	return ui
 69}
 70
 71func (ui *UI) getMargins() (wm, hm int) {
 72	style := ui.common.Styles.App.Copy()
 73	switch ui.activePage {
 74	case selectionPage:
 75		hm += ui.common.Styles.ServerName.GetHeight() +
 76			ui.common.Styles.ServerName.GetVerticalFrameSize()
 77	case repoPage:
 78	}
 79	wm += style.GetHorizontalFrameSize()
 80	hm += style.GetVerticalFrameSize()
 81	if ui.showFooter {
 82		// NOTE: we don't use the footer's style to determine the margins
 83		// because footer.Height() is the height of the footer after applying
 84		// the styles.
 85		hm += ui.footer.Height()
 86	}
 87	return
 88}
 89
 90// ShortHelp implements help.KeyMap.
 91func (ui *UI) ShortHelp() []key.Binding {
 92	b := make([]key.Binding, 0)
 93	switch ui.state {
 94	case errorState:
 95		b = append(b, ui.common.KeyMap.Back)
 96	case readyState:
 97		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
 98	}
 99	if !ui.IsFiltering() {
100		b = append(b, ui.common.KeyMap.Quit)
101	}
102	b = append(b, ui.common.KeyMap.Help)
103	return b
104}
105
106// FullHelp implements help.KeyMap.
107func (ui *UI) FullHelp() [][]key.Binding {
108	b := make([][]key.Binding, 0)
109	switch ui.state {
110	case errorState:
111		b = append(b, []key.Binding{ui.common.KeyMap.Back})
112	case readyState:
113		b = append(b, ui.pages[ui.activePage].FullHelp()...)
114	}
115	h := []key.Binding{
116		ui.common.KeyMap.Help,
117	}
118	if !ui.IsFiltering() {
119		h = append(h, ui.common.KeyMap.Quit)
120	}
121	b = append(b, h)
122	return b
123}
124
125// SetSize implements common.Component.
126func (ui *UI) SetSize(width, height int) {
127	ui.common.SetSize(width, height)
128	wm, hm := ui.getMargins()
129	ui.header.SetSize(width-wm, height-hm)
130	ui.footer.SetSize(width-wm, height-hm)
131	for _, p := range ui.pages {
132		if p != nil {
133			p.SetSize(width-wm, height-hm)
134		}
135	}
136}
137
138// Init implements tea.Model.
139func (ui *UI) Init() tea.Cmd {
140	ui.pages[selectionPage] = selection.New(ui.common)
141	ui.pages[repoPage] = repo.New(ui.common)
142	ui.SetSize(ui.common.Width, ui.common.Height)
143	cmds := make([]tea.Cmd, 0)
144	cmds = append(cmds,
145		ui.pages[selectionPage].Init(),
146		ui.pages[repoPage].Init(),
147	)
148	if ui.initialRepo != "" {
149		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
150	}
151	ui.state = readyState
152	ui.SetSize(ui.common.Width, ui.common.Height)
153	return tea.Batch(cmds...)
154}
155
156// IsFiltering returns true if the selection page is filtering.
157func (ui *UI) IsFiltering() bool {
158	if ui.activePage == selectionPage {
159		if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {
160			return true
161		}
162	}
163	return false
164}
165
166// Update implements tea.Model.
167func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168	logger.Debugf("msg received: %T", msg)
169	cmds := make([]tea.Cmd, 0)
170	switch msg := msg.(type) {
171	case tea.WindowSizeMsg:
172		ui.SetSize(msg.Width, msg.Height)
173		for i, p := range ui.pages {
174			m, cmd := p.Update(msg)
175			ui.pages[i] = m.(common.Component)
176			if cmd != nil {
177				cmds = append(cmds, cmd)
178			}
179		}
180	case tea.KeyMsg, tea.MouseMsg:
181		switch msg := msg.(type) {
182		case tea.KeyMsg:
183			switch {
184			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
185				ui.error = nil
186				ui.state = readyState
187				// Always show the footer on error.
188				ui.showFooter = ui.footer.ShowAll()
189			case key.Matches(msg, ui.common.KeyMap.Help):
190				cmds = append(cmds, footer.ToggleFooterCmd)
191			case key.Matches(msg, ui.common.KeyMap.Quit):
192				if !ui.IsFiltering() {
193					// Stop bubblezone background workers.
194					ui.common.Zone.Close()
195					return ui, tea.Quit
196				}
197			case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
198				ui.activePage = selectionPage
199				// Always show the footer on selection page.
200				ui.showFooter = true
201			}
202		case tea.MouseMsg:
203			switch msg.Type {
204			case tea.MouseLeft:
205				switch {
206				case ui.common.Zone.Get("footer").InBounds(msg):
207					cmds = append(cmds, footer.ToggleFooterCmd)
208				}
209			}
210		}
211	case footer.ToggleFooterMsg:
212		ui.footer.SetShowAll(!ui.footer.ShowAll())
213		// Show the footer when on repo page and shot all help.
214		if ui.error == nil && ui.activePage == repoPage {
215			ui.showFooter = !ui.showFooter
216		}
217	case repo.RepoMsg:
218		ui.common.SetValue(common.RepoKey, msg)
219		ui.activePage = repoPage
220		// Show the footer on repo page if show all is set.
221		ui.showFooter = ui.footer.ShowAll()
222		cmds = append(cmds, repo.UpdateRefCmd(msg))
223	case common.ErrorMsg:
224		ui.error = msg
225		ui.state = errorState
226		ui.showFooter = true
227	case selector.SelectMsg:
228		switch msg.IdentifiableItem.(type) {
229		case selection.Item:
230			if ui.activePage == selectionPage {
231				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
232			}
233		}
234	}
235	h, cmd := ui.header.Update(msg)
236	ui.header = h.(*header.Header)
237	if cmd != nil {
238		cmds = append(cmds, cmd)
239	}
240	f, cmd := ui.footer.Update(msg)
241	ui.footer = f.(*footer.Footer)
242	if cmd != nil {
243		cmds = append(cmds, cmd)
244	}
245	if ui.state != loadingState {
246		m, cmd := ui.pages[ui.activePage].Update(msg)
247		ui.pages[ui.activePage] = m.(common.Component)
248		if cmd != nil {
249			cmds = append(cmds, cmd)
250		}
251	}
252	// This fixes determining the height margin of the footer.
253	ui.SetSize(ui.common.Width, ui.common.Height)
254	return ui, tea.Batch(cmds...)
255}
256
257// View implements tea.Model.
258func (ui *UI) View() string {
259	var view string
260	wm, hm := ui.getMargins()
261	switch ui.state {
262	case loadingState:
263		view = "Loading..."
264	case errorState:
265		err := ui.common.Styles.ErrorTitle.Render("Bummer")
266		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
267		view = ui.common.Styles.Error.Copy().
268			Width(ui.common.Width -
269				wm -
270				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
271			Height(ui.common.Height -
272				hm -
273				ui.common.Styles.Error.GetVerticalFrameSize()).
274			Render(err)
275	case readyState:
276		view = ui.pages[ui.activePage].View()
277	default:
278		view = "Unknown state :/ this is a bug!"
279	}
280	if ui.activePage == selectionPage {
281		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
282	}
283	if ui.showFooter {
284		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
285	}
286	return ui.common.Zone.Scan(
287		ui.common.Styles.App.Render(view),
288	)
289}
290
291func (ui *UI) openRepo(rn string) (backend.Repository, error) {
292	cfg := ui.common.Config()
293	if cfg == nil {
294		return nil, errors.New("config is nil")
295	}
296	repos, err := cfg.Backend.Repositories()
297	if err != nil {
298		logger.Debugf("ui: failed to list repos: %v", err)
299		return nil, err
300	}
301	for _, r := range repos {
302		if r.Name() == rn {
303			return r, nil
304		}
305	}
306	return nil, common.ErrMissingRepo
307}
308
309func (ui *UI) setRepoCmd(rn string) tea.Cmd {
310	return func() tea.Msg {
311		r, err := ui.openRepo(rn)
312		if err != nil {
313			return common.ErrorMsg(err)
314		}
315		return repo.RepoMsg(r)
316	}
317}
318
319func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
320	return func() tea.Msg {
321		r, err := ui.openRepo(rn)
322		if err != nil {
323			return nil
324		}
325		return repo.RepoMsg(r)
326	}
327}