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