1package ssh23import (4 "errors"56 "charm.land/bubbles/v2/key"7 "charm.land/bubbles/v2/list"8 tea "charm.land/bubbletea/v2"9 "charm.land/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)1920type page int2122const (23 selectionPage page = iota24 repoPage25)2627type sessionState int2829const (30 loadingState sessionState = iota31 errorState32 readyState33)3435// UI is the main UI model.36type UI struct {37 serverName string38 initialRepo string39 common common.Common40 pages []common.Component41 activePage page42 state sessionState43 header *header.Header44 footer *footer.Footer45 showFooter bool46 error error47}4849// NewUI returns a new UI model.50func NewUI(c common.Common, initialRepo string) *UI {51 serverName := c.Config().Name52 h := header.New(c, serverName)53 ui := &UI{54 serverName: serverName,55 common: c,56 pages: make([]common.Component, 2), // selection & repo57 activePage: selectionPage,58 state: loadingState,59 header: h,60 initialRepo: initialRepo,61 showFooter: true,62 }63 ui.footer = footer.New(c, ui)64 return ui65}6667func (ui *UI) getMargins() (wm, hm int) {68 style := ui.common.Styles.App69 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 margins79 // because footer.Height() is the height of the footer after applying80 // the styles.81 hm += ui.footer.Height()82 }83 return84}8586// 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 }95 if !ui.IsFiltering() {96 b = append(b, ui.common.KeyMap.Quit)97 }98 b = append(b, ui.common.KeyMap.Help)99 return b100}101102// FullHelp implements help.KeyMap.103func (ui *UI) FullHelp() [][]key.Binding {104 b := make([][]key.Binding, 0)105 switch ui.state {106 case errorState:107 b = append(b, []key.Binding{ui.common.KeyMap.Back})108 case readyState:109 b = append(b, ui.pages[ui.activePage].FullHelp()...)110 }111 h := []key.Binding{112 ui.common.KeyMap.Help,113 }114 if !ui.IsFiltering() {115 h = append(h, ui.common.KeyMap.Quit)116 }117 b = append(b, h)118 return b119}120121// SetSize implements common.Component.122func (ui *UI) SetSize(width, height int) {123 ui.common.SetSize(width, height)124 wm, hm := ui.getMargins()125 ui.header.SetSize(width-wm, height-hm)126 ui.footer.SetSize(width-wm, height-hm)127 for _, p := range ui.pages {128 if p != nil {129 p.SetSize(width-wm, height-hm)130 }131 }132}133134// Init implements tea.Model.135func (ui *UI) Init() tea.Cmd {136 ui.pages[selectionPage] = selection.New(ui.common)137 ui.pages[repoPage] = repo.New(ui.common,138 repo.NewReadme(ui.common),139 repo.NewFiles(ui.common),140 repo.NewLog(ui.common),141 repo.NewRefs(ui.common, git.RefsHeads),142 repo.NewRefs(ui.common, git.RefsTags),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 = readyState154 ui.SetSize(ui.common.Width, ui.common.Height)155 return tea.Batch(cmds...)156}157158// IsFiltering returns true if the selection page is filtering.159func (ui *UI) IsFiltering() bool {160 if ui.activePage == selectionPage {161 if s, ok := ui.pages[selectionPage].(*selection.Selection); ok && s.FilterState() == list.Filtering {162 return true163 }164 }165 return false166}167168// Update implements tea.Model.169func (ui *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {170 ui.common.Logger.Debugf("msg received: %T", msg)171 cmds := make([]tea.Cmd, 0)172 switch msg := msg.(type) {173 case tea.WindowSizeMsg:174 ui.SetSize(msg.Width, msg.Height)175 for i, p := range ui.pages {176 m, cmd := p.Update(msg)177 ui.pages[i] = m.(common.Component)178 if cmd != nil {179 cmds = append(cmds, cmd)180 }181 }182 case tea.KeyPressMsg:183 switch {184 case key.Matches(msg, ui.common.KeyMap.Back) && ui.error != nil:185 ui.error = nil186 ui.state = readyState187 // Always show the footer on error.188 ui.showFooter = ui.footer.ShowAll()189 case key.Matches(msg, ui.common.KeyMap.Help):190 cmds = append(cmds, footer.ToggleFooterCmd)191 case key.Matches(msg, ui.common.KeyMap.Quit):192 if !ui.IsFiltering() {193 // Stop bubblezone background workers.194 ui.common.Zone.Close()195 return ui, tea.Quit196 }197 case ui.activePage == repoPage &&198 ui.pages[ui.activePage].(*repo.Repo).Path() == "" &&199 key.Matches(msg, ui.common.KeyMap.Back):200 ui.activePage = selectionPage201 // Always show the footer on selection page.202 ui.showFooter = true203 }204 case tea.MouseClickMsg:205 switch msg.Button {206 case tea.MouseLeft:207 switch {208 case ui.common.Zone.Get("footer").InBounds(msg):209 cmds = append(cmds, footer.ToggleFooterCmd)210 }211 }212 case footer.ToggleFooterMsg:213 ui.footer.SetShowAll(!ui.footer.ShowAll())214 // Show the footer when on repo page and shot all help.215 if ui.error == nil && ui.activePage == repoPage {216 ui.showFooter = !ui.showFooter217 }218 case repo.RepoMsg:219 ui.common.SetValue(common.RepoKey, msg)220 ui.activePage = repoPage221 // Show the footer on repo page if show all is set.222 ui.showFooter = ui.footer.ShowAll()223 cmds = append(cmds, repo.UpdateRefCmd(msg))224 case common.ErrorMsg:225 ui.error = msg226 ui.state = errorState227 ui.showFooter = true228 case selector.SelectMsg:229 switch msg.IdentifiableItem.(type) {230 case selection.Item:231 if ui.activePage == selectionPage {232 cmds = append(cmds, ui.setRepoCmd(msg.ID()))233 }234 }235 }236 h, cmd := ui.header.Update(msg)237 ui.header = h.(*header.Header)238 if cmd != nil {239 cmds = append(cmds, cmd)240 }241 f, cmd := ui.footer.Update(msg)242 ui.footer = f.(*footer.Footer)243 if cmd != nil {244 cmds = append(cmds, cmd)245 }246 if ui.state != loadingState {247 m, cmd := ui.pages[ui.activePage].Update(msg)248 ui.pages[ui.activePage] = m.(common.Component)249 if cmd != nil {250 cmds = append(cmds, cmd)251 }252 }253 // This fixes determining the height margin of the footer.254 ui.SetSize(ui.common.Width, ui.common.Height)255 return ui, tea.Batch(cmds...)256}257258// View implements tea.Model.259func (ui *UI) View() tea.View {260 var v tea.View261 v.AltScreen = true262 v.MouseMode = tea.MouseModeCellMotion263264 var view string265 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 v.Content = ui.common.Zone.Scan(292 ui.common.Styles.App.Render(view),293 )294 return v295}296297func (ui *UI) openRepo(rn string) (proto.Repository, error) {298 cfg := ui.common.Config()299 if cfg == nil {300 return nil, errors.New("config is nil")301 }302303 ctx := ui.common.Context()304 be := ui.common.Backend()305 repos, err := be.Repositories(ctx)306 if err != nil {307 ui.common.Logger.Debugf("ui: failed to list repos: %v", err)308 return nil, err309 }310 for _, r := range repos {311 if r.Name() == rn {312 return r, nil313 }314 }315 return nil, common.ErrMissingRepo316}317318func (ui *UI) setRepoCmd(rn string) tea.Cmd {319 return func() tea.Msg {320 r, err := ui.openRepo(rn)321 if err != nil {322 return common.ErrorMsg(err)323 }324 return repo.RepoMsg(r)325 }326}327328func (ui *UI) initialRepoCmd(rn string) tea.Cmd {329 return func() tea.Msg {330 r, err := ui.openRepo(rn)331 if err != nil {332 return nil333 }334 return repo.RepoMsg(r)335 }336}