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