ui.go

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