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}