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}