1package ui
2
3import (
4 "github.com/charmbracelet/bubbles/key"
5 "github.com/charmbracelet/bubbles/list"
6 tea "github.com/charmbracelet/bubbletea"
7 "github.com/charmbracelet/lipgloss"
8 "github.com/charmbracelet/soft-serve/config"
9 "github.com/charmbracelet/soft-serve/ui/common"
10 "github.com/charmbracelet/soft-serve/ui/components/footer"
11 "github.com/charmbracelet/soft-serve/ui/components/header"
12 "github.com/charmbracelet/soft-serve/ui/components/selector"
13 "github.com/charmbracelet/soft-serve/ui/git"
14 "github.com/charmbracelet/soft-serve/ui/pages/repo"
15 "github.com/charmbracelet/soft-serve/ui/pages/selection"
16 "github.com/gliderlabs/ssh"
17)
18
19type page int
20
21const (
22 selectionPage page = iota
23 repoPage
24)
25
26type sessionState int
27
28const (
29 startState sessionState = iota
30 errorState
31 loadedState
32)
33
34// UI is the main UI model.
35type UI struct {
36 cfg *config.Config
37 session ssh.Session
38 rs git.GitRepoSource
39 initialRepo string
40 common common.Common
41 pages []common.Component
42 activePage page
43 state sessionState
44 header *header.Header
45 footer *footer.Footer
46 showFooter bool
47 error error
48}
49
50// New returns a new UI model.
51func New(cfg *config.Config, s ssh.Session, c common.Common, initialRepo string) *UI {
52 src := &source{cfg.Source}
53 h := header.New(c, cfg.Name)
54 ui := &UI{
55 cfg: cfg,
56 session: s,
57 rs: src,
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(
140 ui.cfg,
141 ui.session.PublicKey(),
142 ui.common,
143 )
144 ui.pages[repoPage] = repo.New(
145 ui.cfg,
146 ui.common,
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 = loadedState
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 cmds := make([]tea.Cmd, 0)
175 switch msg := msg.(type) {
176 case tea.WindowSizeMsg:
177 ui.SetSize(msg.Width, msg.Height)
178 for i, p := range ui.pages {
179 m, cmd := p.Update(msg)
180 ui.pages[i] = m.(common.Component)
181 if cmd != nil {
182 cmds = append(cmds, cmd)
183 }
184 }
185 case tea.KeyMsg, tea.MouseMsg:
186 switch msg := msg.(type) {
187 case tea.KeyMsg:
188 switch {
189 case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
190 ui.error = nil
191 ui.state = loadedState
192 // Always show the footer on error.
193 ui.showFooter = ui.footer.ShowAll()
194 case key.Matches(msg, ui.common.KeyMap.Help):
195 cmds = append(cmds, footer.ToggleFooterCmd)
196 case key.Matches(msg, ui.common.KeyMap.Quit):
197 if !ui.IsFiltering() {
198 // Stop bubblezone background workers.
199 ui.common.Zone.Close()
200 return ui, tea.Quit
201 }
202 case ui.activePage == repoPage && key.Matches(msg, ui.common.KeyMap.Back):
203 ui.activePage = selectionPage
204 // Always show the footer on selection page.
205 ui.showFooter = true
206 }
207 case tea.MouseMsg:
208 if msg.Type == tea.MouseLeft {
209 switch {
210 case ui.common.Zone.Get("repo-help").InBounds(msg),
211 ui.common.Zone.Get("footer").InBounds(msg):
212 cmds = append(cmds, footer.ToggleFooterCmd)
213 }
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.activePage = repoPage
224 // Show the footer on repo page if show all is set.
225 ui.showFooter = ui.footer.ShowAll()
226 case common.ErrorMsg:
227 ui.error = msg
228 ui.state = errorState
229 ui.showFooter = true
230 return ui, nil
231 case selector.SelectMsg:
232 switch msg.IdentifiableItem.(type) {
233 case selection.Item:
234 if ui.activePage == selectionPage {
235 cmds = append(cmds, ui.setRepoCmd(msg.ID()))
236 }
237 }
238 }
239 h, cmd := ui.header.Update(msg)
240 ui.header = h.(*header.Header)
241 if cmd != nil {
242 cmds = append(cmds, cmd)
243 }
244 f, cmd := ui.footer.Update(msg)
245 ui.footer = f.(*footer.Footer)
246 if cmd != nil {
247 cmds = append(cmds, cmd)
248 }
249 if ui.state == loadedState {
250 m, cmd := ui.pages[ui.activePage].Update(msg)
251 ui.pages[ui.activePage] = m.(common.Component)
252 if cmd != nil {
253 cmds = append(cmds, cmd)
254 }
255 }
256 // This fixes determining the height margin of the footer.
257 ui.SetSize(ui.common.Width, ui.common.Height)
258 return ui, tea.Batch(cmds...)
259}
260
261// View implements tea.Model.
262func (ui *UI) View() string {
263 var view string
264 wm, hm := ui.getMargins()
265 switch ui.state {
266 case startState:
267 view = "Loading..."
268 case errorState:
269 err := ui.common.Styles.ErrorTitle.Render("Bummer")
270 err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
271 view = ui.common.Styles.Error.Copy().
272 Width(ui.common.Width -
273 wm -
274 ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
275 Height(ui.common.Height -
276 hm -
277 ui.common.Styles.Error.GetVerticalFrameSize()).
278 Render(err)
279 case loadedState:
280 view = ui.pages[ui.activePage].View()
281 default:
282 view = "Unknown state :/ this is a bug!"
283 }
284 if ui.activePage == selectionPage {
285 view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
286 }
287 if ui.showFooter {
288 view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
289 }
290 return ui.common.Zone.Scan(
291 ui.common.Styles.App.Render(view),
292 )
293}
294
295func (ui *UI) setRepoCmd(rn string) tea.Cmd {
296 return func() tea.Msg {
297 for _, r := range ui.rs.AllRepos() {
298 if r.Repo() == rn {
299 return repo.RepoMsg(r)
300 }
301 }
302 return common.ErrorMsg(git.ErrMissingRepo)
303 }
304}
305
306func (ui *UI) initialRepoCmd(rn string) tea.Cmd {
307 return func() tea.Msg {
308 for _, r := range ui.rs.AllRepos() {
309 if r.Repo() == rn {
310 return repo.RepoMsg(r)
311 }
312 }
313 return nil
314 }
315}