1package ui
  2
  3import (
  4	"errors"
  5	"log"
  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/ui/common"
 12	"github.com/charmbracelet/soft-serve/ui/components/footer"
 13	"github.com/charmbracelet/soft-serve/ui/components/header"
 14	"github.com/charmbracelet/soft-serve/ui/components/selector"
 15	"github.com/charmbracelet/soft-serve/ui/git"
 16	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 17	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 18)
 19
 20type page int
 21
 22const (
 23	selectionPage page = iota
 24	repoPage
 25)
 26
 27type sessionState int
 28
 29const (
 30	startState sessionState = iota
 31	errorState
 32	loadedState
 33)
 34
 35// UI is the main UI model.
 36type UI struct {
 37	serverName  string
 38	initialRepo string
 39	common      common.Common
 40	pages       []common.Component
 41	activePage  page
 42	state       sessionState
 43	header      *header.Header
 44	footer      *footer.Footer
 45	showFooter  bool
 46	error       error
 47}
 48
 49// New returns a new UI model.
 50func New(c common.Common, initialRepo string) *UI {
 51	var serverName string
 52	if cfg := c.Config(); cfg != nil {
 53		serverName = cfg.ServerName
 54	}
 55	h := header.New(c, serverName)
 56	ui := &UI{
 57		serverName:  serverName,
 58		common:      c,
 59		pages:       make([]common.Component, 2), // selection & repo
 60		activePage:  selectionPage,
 61		state:       startState,
 62		header:      h,
 63		initialRepo: initialRepo,
 64		showFooter:  true,
 65	}
 66	ui.footer = footer.New(c, ui)
 67	return ui
 68}
 69
 70func (ui *UI) getMargins() (wm, hm int) {
 71	style := ui.common.Styles.App.Copy()
 72	switch ui.activePage {
 73	case selectionPage:
 74		hm += ui.common.Styles.ServerName.GetHeight() +
 75			ui.common.Styles.ServerName.GetVerticalFrameSize()
 76	case repoPage:
 77	}
 78	wm += style.GetHorizontalFrameSize()
 79	hm += style.GetVerticalFrameSize()
 80	if ui.showFooter {
 81		// NOTE: we don't use the footer's style to determine the margins
 82		// because footer.Height() is the height of the footer after applying
 83		// the styles.
 84		hm += ui.footer.Height()
 85	}
 86	return
 87}
 88
 89// ShortHelp implements help.KeyMap.
 90func (ui *UI) ShortHelp() []key.Binding {
 91	b := make([]key.Binding, 0)
 92	switch ui.state {
 93	case errorState:
 94		b = append(b, ui.common.KeyMap.Back)
 95	case loadedState:
 96		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
 97	}
 98	if !ui.IsFiltering() {
 99		b = append(b, ui.common.KeyMap.Quit)
100	}
101	b = append(b, ui.common.KeyMap.Help)
102	return b
103}
104
105// FullHelp implements help.KeyMap.
106func (ui *UI) FullHelp() [][]key.Binding {
107	b := make([][]key.Binding, 0)
108	switch ui.state {
109	case errorState:
110		b = append(b, []key.Binding{ui.common.KeyMap.Back})
111	case loadedState:
112		b = append(b, ui.pages[ui.activePage].FullHelp()...)
113	}
114	h := []key.Binding{
115		ui.common.KeyMap.Help,
116	}
117	if !ui.IsFiltering() {
118		h = append(h, ui.common.KeyMap.Quit)
119	}
120	b = append(b, h)
121	return b
122}
123
124// SetSize implements common.Component.
125func (ui *UI) SetSize(width, height int) {
126	ui.common.SetSize(width, height)
127	wm, hm := ui.getMargins()
128	ui.header.SetSize(width-wm, height-hm)
129	ui.footer.SetSize(width-wm, height-hm)
130	for _, p := range ui.pages {
131		if p != nil {
132			p.SetSize(width-wm, height-hm)
133		}
134	}
135}
136
137// Init implements tea.Model.
138func (ui *UI) Init() tea.Cmd {
139	ui.pages[selectionPage] = selection.New(ui.common)
140	ui.pages[repoPage] = repo.New(ui.common)
141	ui.SetSize(ui.common.Width, ui.common.Height)
142	cmds := make([]tea.Cmd, 0)
143	cmds = append(cmds,
144		ui.pages[selectionPage].Init(),
145		ui.pages[repoPage].Init(),
146	)
147	if ui.initialRepo != "" {
148		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
149	}
150	ui.state = loadedState
151	ui.SetSize(ui.common.Width, ui.common.Height)
152	return tea.Batch(cmds...)
153}
154
155// IsFiltering returns true if the selection page is filtering.
156func (ui *UI) IsFiltering() bool {
157	if ui.activePage == selectionPage {
158		if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {
159			return true
160		}
161	}
162	return false
163}
164
165// Update implements tea.Model.
166func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
167	log.Printf("msg received: %T", msg)
168	cmds := make([]tea.Cmd, 0)
169	switch msg := msg.(type) {
170	case tea.WindowSizeMsg:
171		ui.SetSize(msg.Width, msg.Height)
172		for i, p := range ui.pages {
173			m, cmd := p.Update(msg)
174			ui.pages[i] = m.(common.Component)
175			if cmd != nil {
176				cmds = append(cmds, cmd)
177			}
178		}
179	case tea.KeyMsg, tea.MouseMsg:
180		switch msg := msg.(type) {
181		case tea.KeyMsg:
182			switch {
183			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
184				ui.error = nil
185				ui.state = loadedState
186				// Always show the footer on error.
187				ui.showFooter = ui.footer.ShowAll()
188			case key.Matches(msg, ui.common.KeyMap.Help):
189				cmds = append(cmds, footer.ToggleFooterCmd)
190			case key.Matches(msg, ui.common.KeyMap.Quit):
191				if !ui.IsFiltering() {
192					// Stop bubblezone background workers.
193					ui.common.Zone.Close()
194					return ui, tea.Quit
195				}
196			case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
197				ui.activePage = selectionPage
198				// Always show the footer on selection page.
199				ui.showFooter = true
200			}
201		case tea.MouseMsg:
202			switch msg.Type {
203			case tea.MouseLeft:
204				switch {
205				case ui.common.Zone.Get("footer").InBounds(msg):
206					cmds = append(cmds, footer.ToggleFooterCmd)
207				}
208			}
209		}
210	case footer.ToggleFooterMsg:
211		ui.footer.SetShowAll(!ui.footer.ShowAll())
212		// Show the footer when on repo page and shot all help.
213		if ui.error == nil && ui.activePage == repoPage {
214			ui.showFooter = !ui.showFooter
215		}
216	case repo.RepoMsg:
217		ui.common.SetValue(common.RepoKey, msg)
218		ui.activePage = repoPage
219		// Show the footer on repo page if show all is set.
220		ui.showFooter = ui.footer.ShowAll()
221		cmds = append(cmds, repo.UpdateRefCmd(msg))
222	case common.ErrorMsg:
223		ui.error = msg
224		ui.state = errorState
225		ui.showFooter = true
226		return ui, nil
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 == loadedState {
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 startState:
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 loadedState:
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) (*git.Repository, error) {
292	cfg := ui.common.Config()
293	if cfg == nil {
294		return nil, errors.New("config is nil")
295	}
296	repos, err := cfg.ListRepos()
297	if err != nil {
298		log.Printf("ui: failed to list repos: %v", err)
299		return nil, err
300	}
301	for _, r := range repos {
302		if r.Name() == rn {
303			re, err := cfg.Open(rn)
304			if err != nil {
305				log.Printf("ui: failed to open repo: %v", err)
306				return nil, err
307			}
308			return &git.Repository{
309				Info: r,
310				Repo: re,
311			}, nil
312		}
313	}
314	return nil, git.ErrMissingRepo
315}
316
317func (ui *UI) setRepoCmd(rn string) tea.Cmd {
318	return func() tea.Msg {
319		r, err := ui.openRepo(rn)
320		if err != nil {
321			return common.ErrorMsg(err)
322		}
323		return repo.RepoMsg(r)
324	}
325}
326
327func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
328	return func() tea.Msg {
329		r, err := ui.openRepo(rn)
330		if err != nil {
331			return nil
332		}
333		return repo.RepoMsg(r)
334	}
335}