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 { //nolint:exhaustive
 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 { //nolint:exhaustive
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		repo.NewReadme(ui.common),
139		repo.NewFiles(ui.common),
140		repo.NewLog(ui.common),
141		repo.NewRefs(ui.common, git.RefsHeads),
142		repo.NewRefs(ui.common, git.RefsTags),
143	)
144	ui.SetSize(ui.common.Width, ui.common.Height)
145	cmds := make([]tea.Cmd, 0)
146	cmds = append(cmds,
147		ui.pages[selectionPage].Init(),
148		ui.pages[repoPage].Init(),
149	)
150	if ui.initialRepo != "" {
151		cmds = append(cmds, ui.initialRepoCmd(ui.initialRepo))
152	}
153	ui.state = readyState
154	ui.SetSize(ui.common.Width, ui.common.Height)
155	return tea.Batch(cmds...)
156}
157
158// IsFiltering returns true if the selection page is filtering.
159func (ui *UI) IsFiltering() bool {
160	if ui.activePage == selectionPage {
161		if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {
162			return true
163		}
164	}
165	return false
166}
167
168// Update implements tea.Model.
169func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
170	ui.common.Logger.Debugf("msg received: %T", msg)
171	cmds := make([]tea.Cmd, 0)
172	switch msg := msg.(type) {
173	case tea.WindowSizeMsg:
174		ui.SetSize(msg.Width, msg.Height)
175		for i, p := range ui.pages {
176			m, cmd := p.Update(msg)
177			ui.pages[i] = m.(common.Component)
178			if cmd != nil {
179				cmds = append(cmds, cmd)
180			}
181		}
182	case tea.KeyPressMsg:
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 &&
198			ui.pages[ui.activePage].(*repo.Repo).Path() == "" &&
199			key.Matches(msg, ui.common.KeyMap.Back):
200			ui.activePage = selectionPage
201			// Always show the footer on selection page.
202			ui.showFooter = true
203		}
204	case tea.MouseClickMsg:
205		switch msg.Button {
206		case tea.MouseLeft:
207			switch {
208			case ui.common.Zone.Get("footer").InBounds(msg):
209				cmds = append(cmds, footer.ToggleFooterCmd)
210			}
211		}
212	case footer.ToggleFooterMsg:
213		ui.footer.SetShowAll(!ui.footer.ShowAll())
214		// Show the footer when on repo page and shot all help.
215		if ui.error == nil && ui.activePage == repoPage {
216			ui.showFooter = !ui.showFooter
217		}
218	case repo.RepoMsg:
219		ui.common.SetValue(common.RepoKey, msg)
220		ui.activePage = repoPage
221		// Show the footer on repo page if show all is set.
222		ui.showFooter = ui.footer.ShowAll()
223		cmds = append(cmds, repo.UpdateRefCmd(msg))
224	case common.ErrorMsg:
225		ui.error = msg
226		ui.state = errorState
227		ui.showFooter = true
228	case selector.SelectMsg:
229		switch msg.IdentifiableItem.(type) {
230		case selection.Item:
231			if ui.activePage == selectionPage {
232				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
233			}
234		}
235	}
236	h, cmd := ui.header.Update(msg)
237	ui.header = h.(*header.Header)
238	if cmd != nil {
239		cmds = append(cmds, cmd)
240	}
241	f, cmd := ui.footer.Update(msg)
242	ui.footer = f.(*footer.Footer)
243	if cmd != nil {
244		cmds = append(cmds, cmd)
245	}
246	if ui.state != loadingState {
247		m, cmd := ui.pages[ui.activePage].Update(msg)
248		ui.pages[ui.activePage] = m.(common.Component)
249		if cmd != nil {
250			cmds = append(cmds, cmd)
251		}
252	}
253	// This fixes determining the height margin of the footer.
254	ui.SetSize(ui.common.Width, ui.common.Height)
255	return ui, tea.Batch(cmds...)
256}
257
258// View implements tea.Model.
259func (ui *UI) View() string {
260	var view string
261	wm, hm := ui.getMargins()
262	switch ui.state {
263	case loadingState:
264		view = "Loading..."
265	case errorState:
266		err := ui.common.Styles.ErrorTitle.Render("Bummer")
267		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
268		view = ui.common.Styles.Error.
269			Width(ui.common.Width -
270				wm -
271				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
272			Height(ui.common.Height -
273				hm -
274				ui.common.Styles.Error.GetVerticalFrameSize()).
275			Render(err)
276	case readyState:
277		view = ui.pages[ui.activePage].View()
278	default:
279		view = "Unknown state :/ this is a bug!"
280	}
281	if ui.activePage == selectionPage {
282		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
283	}
284	if ui.showFooter {
285		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
286	}
287	return ui.common.Zone.Scan(
288		ui.common.Styles.App.Render(view),
289	)
290}
291
292func (ui *UI) openRepo(rn string) (proto.Repository, error) {
293	cfg := ui.common.Config()
294	if cfg == nil {
295		return nil, errors.New("config is nil")
296	}
297
298	ctx := ui.common.Context()
299	be := ui.common.Backend()
300	repos, err := be.Repositories(ctx)
301	if err != nil {
302		ui.common.Logger.Debugf("ui: failed to list repos: %v", err)
303		return nil, err //nolint:wrapcheck
304	}
305	for _, r := range repos {
306		if r.Name() == rn {
307			return r, nil
308		}
309	}
310	return nil, common.ErrMissingRepo
311}
312
313func (ui *UI) setRepoCmd(rn string) tea.Cmd {
314	return func() tea.Msg {
315		r, err := ui.openRepo(rn)
316		if err != nil {
317			return common.ErrorMsg(err)
318		}
319		return repo.RepoMsg(r)
320	}
321}
322
323func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
324	return func() tea.Msg {
325		r, err := ui.openRepo(rn)
326		if err != nil {
327			return nil
328		}
329		return repo.RepoMsg(r)
330	}
331}