1package ui
  2
  3import (
  4	"github.com/charmbracelet/bubbles/key"
  5	tea "github.com/charmbracelet/bubbletea"
  6	"github.com/charmbracelet/lipgloss"
  7	"github.com/charmbracelet/soft-serve/config"
  8	"github.com/charmbracelet/soft-serve/ui/common"
  9	"github.com/charmbracelet/soft-serve/ui/components/footer"
 10	"github.com/charmbracelet/soft-serve/ui/components/header"
 11	"github.com/charmbracelet/soft-serve/ui/components/selector"
 12	"github.com/charmbracelet/soft-serve/ui/git"
 13	"github.com/charmbracelet/soft-serve/ui/pages/repo"
 14	"github.com/charmbracelet/soft-serve/ui/pages/selection"
 15	"github.com/gliderlabs/ssh"
 16)
 17
 18type page int
 19
 20const (
 21	selectionPage page = iota
 22	repoPage
 23)
 24
 25type sessionState int
 26
 27const (
 28	startState sessionState = iota
 29	errorState
 30	loadedState
 31)
 32
 33// UI is the main UI model.
 34type UI struct {
 35	cfg         *config.Config
 36	session     ssh.Session
 37	rs          git.GitRepoSource
 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(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI {
 51	src := &source{cfg.Source}
 52	h := header.New(c, cfg.Name)
 53	ui := &UI{
 54		cfg:         cfg,
 55		session:     s,
 56		rs:          src,
 57		common:      c,
 58		pages:       make([]common.Component, 2), // selection & repo
 59		activePage:  selectionPage,
 60		state:       startState,
 61		header:      h,
 62		initialRepo: initialRepo,
 63		showFooter:  true,
 64	}
 65	ui.footer = footer.New(c, ui)
 66	return ui
 67}
 68
 69func (ui *UI) getMargins() (wm, hm int) {
 70	style := ui.common.Styles.App.Copy()
 71	switch ui.activePage {
 72	case selectionPage:
 73		hm += ui.common.Styles.ServerName.GetHeight() +
 74			ui.common.Styles.ServerName.GetVerticalFrameSize()
 75	case repoPage:
 76	}
 77	wm += style.GetHorizontalFrameSize()
 78	hm += style.GetVerticalFrameSize()
 79	if ui.showFooter {
 80		// NOTE: we don't use the footer's style to determine the margins
 81		// because footer.Height() is the height of the footer after applying
 82		// the styles.
 83		hm += ui.footer.Height()
 84	}
 85	return
 86}
 87
 88// ShortHelp implements help.KeyMap.
 89func (ui *UI) ShortHelp() []key.Binding {
 90	b := make([]key.Binding, 0)
 91	switch ui.state {
 92	case errorState:
 93		b = append(b, ui.common.KeyMap.Back)
 94	case loadedState:
 95		b = append(b, ui.pages[ui.activePage].ShortHelp()...)
 96	}
 97	b = append(b,
 98		ui.common.KeyMap.Quit,
 99		ui.common.KeyMap.Help,
100	)
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 loadedState:
111		b = append(b, ui.pages[ui.activePage].FullHelp()...)
112	}
113	b = append(b, []key.Binding{
114		ui.common.KeyMap.Quit,
115		ui.common.KeyMap.Help,
116	})
117	return b
118}
119
120// SetSize implements common.Component.
121func (ui *UI) SetSize(width, height int) {
122	ui.common.SetSize(width, height)
123	wm, hm := ui.getMargins()
124	ui.header.SetSize(width-wm, height-hm)
125	ui.footer.SetSize(width-wm, height-hm)
126	for _, p := range ui.pages {
127		if p != nil {
128			p.SetSize(width-wm, height-hm)
129		}
130	}
131}
132
133// Init implements tea.Model.
134func (ui *UI) Init() tea.Cmd {
135	ui.pages[selectionPage] = selection.New(
136		ui.cfg,
137		ui.session.PublicKey(),
138		ui.common,
139	)
140	ui.pages[repoPage] = repo.New(
141		ui.cfg,
142		ui.common,
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 = loadedState
154	ui.SetSize(ui.common.Width, ui.common.Height)
155	return tea.Batch(cmds...)
156}
157
158// Update implements tea.Model.
159func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
160	cmds := make([]tea.Cmd, 0)
161	switch msg := msg.(type) {
162	case tea.WindowSizeMsg:
163		ui.SetSize(msg.Width, msg.Height)
164		for i, p := range ui.pages {
165			m, cmd := p.Update(msg)
166			ui.pages[i] = m.(common.Component)
167			if cmd != nil {
168				cmds = append(cmds, cmd)
169			}
170		}
171	case tea.KeyMsg, tea.MouseMsg:
172		switch msg := msg.(type) {
173		case tea.KeyMsg:
174			switch {
175			case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
176				ui.error = nil
177				ui.state = loadedState
178				// Always show the footer on error.
179				ui.showFooter = ui.footer.ShowAll()
180			case key.Matches(msg, ui.common.KeyMap.Help):
181				cmds = append(cmds, footer.ToggleFooterCmd)
182			case key.Matches(msg, ui.common.KeyMap.Quit):
183				// Stop bubblezone background workers.
184				ui.common.Zone.Close()
185				return ui, tea.Quit
186			case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
187				ui.activePage = selectionPage
188				// Always show the footer on selection page.
189				ui.showFooter = true
190			}
191		case tea.MouseMsg:
192			if msg.Type == tea.MouseLeft {
193				switch {
194				case ui.common.Zone.Get("repo-help").InBounds(msg),
195					ui.common.Zone.Get("footer").InBounds(msg):
196					cmds = append(cmds, footer.ToggleFooterCmd)
197				}
198			}
199		}
200	case footer.ToggleFooterMsg:
201		ui.footer.SetShowAll(!ui.footer.ShowAll())
202		// Show the footer when on repo page and shot all help.
203		if ui.error == nil && ui.activePage == repoPage {
204			ui.showFooter = !ui.showFooter
205		}
206	case repo.RepoMsg:
207		ui.activePage = repoPage
208		// Show the footer on repo page if show all is set.
209		ui.showFooter = ui.footer.ShowAll()
210	case common.ErrorMsg:
211		ui.error = msg
212		ui.state = errorState
213		ui.showFooter = true
214		return ui, nil
215	case selector.SelectMsg:
216		switch msg.IdentifiableItem.(type) {
217		case selection.Item:
218			if ui.activePage == selectionPage {
219				cmds = append(cmds, ui.setRepoCmd(msg.ID()))
220			}
221		}
222	}
223	h, cmd := ui.header.Update(msg)
224	ui.header = h.(*header.Header)
225	if cmd != nil {
226		cmds = append(cmds, cmd)
227	}
228	f, cmd := ui.footer.Update(msg)
229	ui.footer = f.(*footer.Footer)
230	if cmd != nil {
231		cmds = append(cmds, cmd)
232	}
233	if ui.state == loadedState {
234		m, cmd := ui.pages[ui.activePage].Update(msg)
235		ui.pages[ui.activePage] = m.(common.Component)
236		if cmd != nil {
237			cmds = append(cmds, cmd)
238		}
239	}
240	// This fixes determining the height margin of the footer.
241	ui.SetSize(ui.common.Width, ui.common.Height)
242	return ui, tea.Batch(cmds...)
243}
244
245// View implements tea.Model.
246func (ui *UI) View() string {
247	var view string
248	wm, hm := ui.getMargins()
249	switch ui.state {
250	case startState:
251		view = "Loading..."
252	case errorState:
253		err := ui.common.Styles.ErrorTitle.Render("Bummer")
254		err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
255		view = ui.common.Styles.Error.Copy().
256			Width(ui.common.Width -
257				wm -
258				ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
259			Height(ui.common.Height -
260				hm -
261				ui.common.Styles.Error.GetVerticalFrameSize()).
262			Render(err)
263	case loadedState:
264		view = ui.pages[ui.activePage].View()
265	default:
266		view = "Unknown state :/ this is a bug!"
267	}
268	if ui.activePage == selectionPage {
269		view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
270	}
271	if ui.showFooter {
272		view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
273	}
274	return ui.common.Zone.Scan(
275		ui.common.Styles.App.Render(view),
276	)
277}
278
279func (ui *UI) setRepoCmd(rn string) tea.Cmd {
280	return func() tea.Msg {
281		for _, r := range ui.rs.AllRepos() {
282			if r.Repo() == rn {
283				return repo.RepoMsg(r)
284			}
285		}
286		return common.ErrorMsg(git.ErrMissingRepo)
287	}
288}
289
290func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
291	return func() tea.Msg {
292		for _, r := range ui.rs.AllRepos() {
293			if r.Repo() == rn {
294				return repo.RepoMsg(r)
295			}
296		}
297		return nil
298	}
299}