1package ssh
2
3import (
4 "errors"
5
6 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/list"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 lipgloss "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/soft-serve/git"
11 "github.com/charmbracelet/soft-serve/pkg/proto"
12 "github.com/charmbracelet/soft-serve/pkg/ui/common"
13 "github.com/charmbracelet/soft-serve/pkg/ui/components/footer"
14 "github.com/charmbracelet/soft-serve/pkg/ui/components/header"
15 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"
16 "github.com/charmbracelet/soft-serve/pkg/ui/pages/repo"
17 "github.com/charmbracelet/soft-serve/pkg/ui/pages/selection"
18)
19
20type page int
21
22const (
23 selectionPage page = iota
24 repoPage
25)
26
27type sessionState int
28
29const (
30 loadingState sessionState = iota
31 errorState
32 readyState
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// NewUI returns a new UI model.
50func NewUI(c common.Common, initialRepo string) *UI {
51 serverName := c.Config().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
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 case loadingState:
95 // No key bindings while loading
96 }
97 if !ui.IsFiltering() {
98 b = append(b, ui.common.KeyMap.Quit)
99 }
100 b = append(b, ui.common.KeyMap.Help)
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 readyState:
111 b = append(b, ui.pages[ui.activePage].FullHelp()...)
112 case loadingState:
113 // No key bindings while loading
114 }
115 h := []key.Binding{
116 ui.common.KeyMap.Help,
117 }
118 if !ui.IsFiltering() {
119 h = append(h, ui.common.KeyMap.Quit)
120 }
121 b = append(b, h)
122 return b
123}
124
125// SetSize implements common.Component.
126func (ui *UI) SetSize(width, height int) {
127 ui.common.SetSize(width, height)
128 wm, hm := ui.getMargins()
129 ui.header.SetSize(width-wm, height-hm)
130 ui.footer.SetSize(width-wm, height-hm)
131 for _, p := range ui.pages {
132 if p != nil {
133 p.SetSize(width-wm, height-hm)
134 }
135 }
136}
137
138// Init implements tea.Model.
139func (ui *UI) Init() tea.Cmd {
140 ui.pages[selectionPage] = selection.New(ui.common)
141 ui.pages[repoPage] = repo.New(ui.common,
142 repo.NewReadme(ui.common),
143 repo.NewFiles(ui.common),
144 repo.NewLog(ui.common),
145 repo.NewRefs(ui.common, git.RefsHeads),
146 repo.NewRefs(ui.common, git.RefsTags),
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 = readyState
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 ui.common.Logger.Debugf("msg received: %T", msg)
175 cmds := make([]tea.Cmd, 0)
176 switch msg := msg.(type) {
177 case tea.WindowSizeMsg:
178 ui.SetSize(msg.Width, msg.Height)
179 for i, p := range ui.pages {
180 m, cmd := p.Update(msg)
181 ui.pages[i] = m.(common.Component)
182 if cmd != nil {
183 cmds = append(cmds, cmd)
184 }
185 }
186 case tea.KeyPressMsg:
187 switch {
188 case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:
189 ui.error = nil
190 ui.state = readyState
191 // Always show the footer on error.
192 ui.showFooter = ui.footer.ShowAll()
193 case key.Matches(msg, ui.common.KeyMap.Help):
194 cmds = append(cmds, footer.ToggleFooterCmd)
195 case key.Matches(msg, ui.common.KeyMap.Quit):
196 if !ui.IsFiltering() {
197 // Stop bubblezone background workers.
198 ui.common.Zone.Close()
199 return ui, tea.Quit
200 }
201 case ui.activePage == repoPage &&
202 ui.pages[ui.activePage].(*repo.Repo).Path() == "" &&
203 key.Matches(msg, ui.common.KeyMap.Back):
204 ui.activePage = selectionPage
205 // Always show the footer on selection page.
206 ui.showFooter = true
207 }
208 case tea.MouseClickMsg:
209 switch msg.Button {
210 case tea.MouseLeft:
211 switch {
212 case ui.common.Zone.Get("footer").InBounds(msg):
213 cmds = append(cmds, footer.ToggleFooterCmd)
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.common.SetValue(common.RepoKey, msg)
224 ui.activePage = repoPage
225 // Show the footer on repo page if show all is set.
226 ui.showFooter = ui.footer.ShowAll()
227 cmds = append(cmds, repo.UpdateRefCmd(msg))
228 case common.ErrorMsg:
229 ui.error = msg
230 ui.state = errorState
231 ui.showFooter = true
232 case selector.SelectMsg:
233 switch msg.IdentifiableItem.(type) {
234 case selection.Item:
235 if ui.activePage == selectionPage {
236 cmds = append(cmds, ui.setRepoCmd(msg.ID()))
237 }
238 }
239 }
240 h, cmd := ui.header.Update(msg)
241 ui.header = h.(*header.Header)
242 if cmd != nil {
243 cmds = append(cmds, cmd)
244 }
245 f, cmd := ui.footer.Update(msg)
246 ui.footer = f.(*footer.Footer)
247 if cmd != nil {
248 cmds = append(cmds, cmd)
249 }
250 if ui.state != loadingState {
251 m, cmd := ui.pages[ui.activePage].Update(msg)
252 ui.pages[ui.activePage] = m.(common.Component)
253 if cmd != nil {
254 cmds = append(cmds, cmd)
255 }
256 }
257 // This fixes determining the height margin of the footer.
258 ui.SetSize(ui.common.Width, ui.common.Height)
259 return ui, tea.Batch(cmds...)
260}
261
262// View implements tea.Model.
263func (ui *UI) View() string {
264 var view string
265 wm, hm := ui.getMargins()
266 switch ui.state {
267 case loadingState:
268 view = "Loading..."
269 case errorState:
270 err := ui.common.Styles.ErrorTitle.Render("Bummer")
271 err += ui.common.Styles.ErrorBody.Render(ui.error.Error())
272 view = ui.common.Styles.Error.
273 Width(ui.common.Width -
274 wm -
275 ui.common.Styles.ErrorBody.GetHorizontalFrameSize()).
276 Height(ui.common.Height -
277 hm -
278 ui.common.Styles.Error.GetVerticalFrameSize()).
279 Render(err)
280 case readyState:
281 view = ui.pages[ui.activePage].View()
282 default:
283 view = "Unknown state :/ this is a bug!"
284 }
285 if ui.activePage == selectionPage {
286 view = lipgloss.JoinVertical(lipgloss.Left, ui.header.View(), view)
287 }
288 if ui.showFooter {
289 view = lipgloss.JoinVertical(lipgloss.Left, view, ui.footer.View())
290 }
291 return ui.common.Zone.Scan(
292 ui.common.Styles.App.Render(view),
293 )
294}
295
296func (ui *UI) openRepo(rn string) (proto.Repository, error) {
297 cfg := ui.common.Config()
298 if cfg == nil {
299 return nil, errors.New("config is nil")
300 }
301
302 ctx := ui.common.Context()
303 be := ui.common.Backend()
304 repos, err := be.Repositories(ctx)
305 if err != nil {
306 ui.common.Logger.Debugf("ui: failed to list repos: %v", err)
307 return nil, err
308 }
309 for _, r := range repos {
310 if r.Name() == rn {
311 return r, nil
312 }
313 }
314 return nil, common.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}