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}