ui.go

  1package ssh
  2
  3import (
  4	"errors"
  5
  6	"github.com/charmbracelet/bubbles/v2/key"
  7	"github.com/charmbracelet/bubbles/v2/list"
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/lipgloss/v2"
 10	"github.com/charmbracelet/soft-serve/git"
 11	"github.com/charmbracelet/soft-serve/pkg/proto"
 12	"github.com/charmbracelet/soft-serve/pkg/ui/common"
 13	"github.com/charmbracelet/soft-serve/pkg/ui/components/footer"
 14	"github.com/charmbracelet/soft-serve/pkg/ui/components/header"
 15	"github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
 16	"github.com/charmbracelet/soft-serve/pkg/ui/pages/repo"
 17	"github.com/charmbracelet/soft-serve/pkg/ui/pages/selection"
 18)
 19
 20type page int
 21
 22const (
 23	selectionPage page = iota
 24	repoPage
 25)
 26
 27type sessionState int
 28
 29const (
 30	loadingState sessionState = iota
 31	errorState
 32	readyState
 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// NewUI returns a new UI model.
 50func NewUI(c common.Common, initialRepo string) *UI {
 51	serverName := c.Config().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
 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	case loadingState:
 95		// No key bindings while loading
 96	}
 97	if !ui.IsFiltering() {
 98		b = append(b, ui.common.KeyMap.Quit)
 99	}
100	b = append(b, ui.common.KeyMap.Help)
101	return b
102}
103
104// FullHelp implements help.KeyMap.
105func (ui *UI) FullHelp() [][]key.Binding {
106	b := make([][]key.Binding, 0)
107	switch ui.state {
108	case errorState:
109		b = append(b, []key.Binding{ui.common.KeyMap.Back})
110	case readyState:
111		b = append(b, ui.pages[ui.activePage].FullHelp()...)
112	case loadingState:
113		// No key bindings while loading
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		repo.NewReadme(ui.common),
143		repo.NewFiles(ui.common),
144		repo.NewLog(ui.common),
145		repo.NewRefs(ui.common, git.RefsHeads),
146		repo.NewRefs(ui.common, git.RefsTags),
147	)
148	ui.SetSize(ui.common.Width, ui.common.Height)
149	cmds := make([]tea.Cmd, 0)
150	cmds = append(cmds,
151		ui.pages[selectionPage].Init(),
152		ui.pages[repoPage].Init(),
153	)
154	if ui.initialRepo != "" {
155		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
156	}
157	ui.state = readyState
158	ui.SetSize(ui.common.Width, ui.common.Height)
159	return tea.Batch(cmds...)
160}
161
162// IsFiltering returns true if the selection page is filtering.
163func (ui *UI) IsFiltering() bool {
164	if ui.activePage == selectionPage {
165		if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {
166			return true
167		}
168	}
169	return false
170}
171
172// Update implements tea.Model.
173func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
174	ui.common.Logger.Debugf("msg received: %T", msg)
175	cmds := make([]tea.Cmd, 0)
176	switch msg := msg.(type) {
177	case tea.WindowSizeMsg:
178		ui.SetSize(msg.Width, msg.Height)
179		for i, p := range ui.pages {
180			m, cmd := p.Update(msg)
181			ui.pages[i] = m.(common.Component)
182			if cmd != nil {
183				cmds = append(cmds, cmd)
184			}
185		}
186	case tea.KeyPressMsg:
187		switch {
188		case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
189			ui.error = nil
190			ui.state = readyState
191			// Always show the footer on error.
192			ui.showFooter = ui.footer.ShowAll()
193		case key.Matches(msg, ui.common.KeyMap.Help):
194			cmds = append(cmds, footer.ToggleFooterCmd)
195		case key.Matches(msg, ui.common.KeyMap.Quit):
196			if !ui.IsFiltering() {
197				// Stop bubblezone background workers.
198				ui.common.Zone.Close()
199				return ui, tea.Quit
200			}
201		case ui.activePage == repoPage &&
202			ui.pages[ui.activePage].(*repo.Repo).Path() == "" &&
203			key.Matches(msg, ui.common.KeyMap.Back):
204			ui.activePage = selectionPage
205			// Always show the footer on selection page.
206			ui.showFooter = true
207		}
208	case tea.MouseClickMsg:
209		switch msg.Button {
210		case tea.MouseLeft:
211			switch {
212			case ui.common.Zone.Get("footer").InBounds(msg):
213				cmds = append(cmds, footer.ToggleFooterCmd)
214			}
215		}
216	case footer.ToggleFooterMsg:
217		ui.footer.SetShowAll(!ui.footer.ShowAll())
218		// Show the footer when on repo page and shot all help.
219		if ui.error == nil && ui.activePage == repoPage {
220			ui.showFooter = !ui.showFooter
221		}
222	case repo.RepoMsg:
223		ui.common.SetValue(common.RepoKey, msg)
224		ui.activePage = repoPage
225		// Show the footer on repo page if show all is set.
226		ui.showFooter = ui.footer.ShowAll()
227		cmds = append(cmds, repo.UpdateRefCmd(msg))
228	case common.ErrorMsg:
229		ui.error = msg
230		ui.state = errorState
231		ui.showFooter = true
232	case selector.SelectMsg:
233		switch msg.IdentifiableItem.(type) {
234		case selection.Item:
235			if ui.activePage == selectionPage {
236				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
237			}
238		}
239	}
240	h, cmd := ui.header.Update(msg)
241	ui.header = h.(*header.Header)
242	if cmd != nil {
243		cmds = append(cmds, cmd)
244	}
245	f, cmd := ui.footer.Update(msg)
246	ui.footer = f.(*footer.Footer)
247	if cmd != nil {
248		cmds = append(cmds, cmd)
249	}
250	if ui.state != loadingState {
251		m, cmd := ui.pages[ui.activePage].Update(msg)
252		ui.pages[ui.activePage] = m.(common.Component)
253		if cmd != nil {
254			cmds = append(cmds, cmd)
255		}
256	}
257	// This fixes determining the height margin of the footer.
258	ui.SetSize(ui.common.Width, ui.common.Height)
259	return ui, tea.Batch(cmds...)
260}
261
262// View implements tea.Model.
263func (ui *UI) View() string {
264	var view string
265	wm, hm := ui.getMargins()
266	switch ui.state {
267	case loadingState:
268		view = "Loading..."
269	case errorState:
270		err := ui.common.Styles.ErrorTitle.Render("Bummer")
271		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
272		view = ui.common.Styles.Error.
273			Width(ui.common.Width -
274				wm -
275				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
276			Height(ui.common.Height -
277				hm -
278				ui.common.Styles.Error.GetVerticalFrameSize()).
279			Render(err)
280	case readyState:
281		view = ui.pages[ui.activePage].View()
282	default:
283		view = "Unknown state :/ this is a bug!"
284	}
285	if ui.activePage == selectionPage {
286		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
287	}
288	if ui.showFooter {
289		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
290	}
291	return ui.common.Zone.Scan(
292		ui.common.Styles.App.Render(view),
293	)
294}
295
296func (ui *UI) openRepo(rn string) (proto.Repository, error) {
297	cfg := ui.common.Config()
298	if cfg == nil {
299		return nil, errors.New("config is nil")
300	}
301
302	ctx := ui.common.Context()
303	be := ui.common.Backend()
304	repos, err := be.Repositories(ctx)
305	if err != nil {
306		ui.common.Logger.Debugf("ui: failed to list repos: %v", err)
307		return nil, err
308	}
309	for _, r := range repos {
310		if r.Name() == rn {
311			return r, nil
312		}
313	}
314	return nil, common.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}