1package repo23import (4 "fmt"5 "strings"67 "charm.land/bubbles/v2/help"8 "charm.land/bubbles/v2/key"9 "charm.land/bubbles/v2/spinner"10 tea "charm.land/bubbletea/v2"11 "charm.land/lipgloss/v2"12 "github.com/charmbracelet/soft-serve/git"13 "github.com/charmbracelet/soft-serve/pkg/proto"14 "github.com/charmbracelet/soft-serve/pkg/ui/common"15 "github.com/charmbracelet/soft-serve/pkg/ui/components/footer"16 "github.com/charmbracelet/soft-serve/pkg/ui/components/selector"17 "github.com/charmbracelet/soft-serve/pkg/ui/components/statusbar"18 "github.com/charmbracelet/soft-serve/pkg/ui/components/tabs"19)2021type state int2223const (24 loadingState state = iota25 readyState26)2728// EmptyRepoMsg is a message to indicate that the repository is empty.29type EmptyRepoMsg struct{}3031// CopyURLMsg is a message to copy the URL of the current repository.32type CopyURLMsg struct{}3334// RepoMsg is a message that contains a git.Repository.35type RepoMsg proto.Repository //nolint:revive3637// GoBackMsg is a message to go back to the previous view.38type GoBackMsg struct{}3940// CopyMsg is a message to indicate copied text.41type CopyMsg struct {42 Text string43 Message string44}4546// SwitchTabMsg is a message to switch tabs.47type SwitchTabMsg common.TabComponent4849// Repo is a view for a git repository.50type Repo struct {51 common common.Common52 selectedRepo proto.Repository53 activeTab int54 tabs *tabs.Tabs55 statusbar *statusbar.Model56 panes []common.TabComponent57 ref *git.Reference58 state state59 spinner spinner.Model60 panesReady []bool61}6263// New returns a new Repo.64func New(c common.Common, comps ...common.TabComponent) *Repo {65 sb := statusbar.New(c)66 ts := make([]string, 0)67 for _, c := range comps {68 ts = append(ts, c.TabName())69 }70 c.Logger = c.Logger.WithPrefix("ui.repo")71 tb := tabs.New(c, ts)72 // Make sure the order matches the order of tab constants above.73 s := spinner.New(spinner.WithSpinner(spinner.Dot),74 spinner.WithStyle(c.Styles.Spinner))75 r := &Repo{76 common: c,77 tabs: tb,78 statusbar: sb,79 panes: comps,80 state: loadingState,81 spinner: s,82 panesReady: make([]bool, len(comps)),83 }84 return r85}8687func (r *Repo) getMargins() (int, int) {88 hh := lipgloss.Height(r.headerView())89 hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +90 hh +91 r.common.Styles.Repo.Header.GetVerticalFrameSize() +92 r.common.Styles.StatusBar.GetHeight()93 return 0, hm94}9596// SetSize implements common.Component.97func (r *Repo) SetSize(width, height int) {98 r.common.SetSize(width, height)99 _, hm := r.getMargins()100 r.tabs.SetSize(width, height-hm)101 r.statusbar.SetSize(width, height-hm)102 for _, p := range r.panes {103 p.SetSize(width, height-hm)104 }105}106107// Path returns the current component path.108func (r *Repo) Path() string {109 return r.panes[r.activeTab].Path()110}111112func (r *Repo) commonHelp() []key.Binding {113 b := make([]key.Binding, 0)114 back := r.common.KeyMap.Back115 back.SetHelp("esc", "back to menu")116 tab := r.common.KeyMap.Section117 tab.SetHelp("tab", "switch tab")118 b = append(b, back)119 b = append(b, tab)120 return b121}122123// ShortHelp implements help.KeyMap.124func (r *Repo) ShortHelp() []key.Binding {125 b := r.commonHelp()126 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)127 return b128}129130// FullHelp implements help.KeyMap.131func (r *Repo) FullHelp() [][]key.Binding {132 b := make([][]key.Binding, 0)133 b = append(b, r.commonHelp())134 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)135 return b136}137138// Init implements tea.View.139func (r *Repo) Init() tea.Cmd {140 r.state = loadingState141 r.activeTab = 0142 return tea.Batch(143 r.tabs.Init(),144 r.statusbar.Init(),145 r.spinner.Tick,146 )147}148149// Update implements tea.Model.150func (r *Repo) Update(msg tea.Msg) (common.Model, tea.Cmd) {151 cmds := make([]tea.Cmd, 0)152 switch msg := msg.(type) {153 case RepoMsg:154 // Set the state to loading when we get a new repository.155 r.selectedRepo = msg156 cmds = append(cmds,157 r.Init(),158 // This will set the selected repo in each pane's model.159 r.updateModels(msg),160 )161 case RefMsg:162 r.ref = msg163 cmds = append(cmds, r.updateModels(msg))164 r.state = readyState165 case tabs.SelectTabMsg:166 r.activeTab = int(msg)167 t, cmd := r.tabs.Update(msg)168 r.tabs = t.(*tabs.Tabs)169 if cmd != nil {170 cmds = append(cmds, cmd)171 }172 case tabs.ActiveTabMsg:173 r.activeTab = int(msg)174 case tea.KeyPressMsg, tea.MouseClickMsg:175 t, cmd := r.tabs.Update(msg)176 r.tabs = t.(*tabs.Tabs)177 if cmd != nil {178 cmds = append(cmds, cmd)179 }180 if r.selectedRepo != nil {181 urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())182 cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())183 if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {184 cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))185 }186 }187 switch msg := msg.(type) {188 case tea.MouseClickMsg:189 switch msg.Button {190 case tea.MouseLeft:191 switch {192 case r.common.Zone.Get("repo-help").InBounds(msg):193 cmds = append(cmds, footer.ToggleFooterCmd)194 }195 case tea.MouseRight:196 switch {197 case r.common.Zone.Get("repo-main").InBounds(msg):198 cmds = append(cmds, goBackCmd)199 }200 }201 }202 switch msg := msg.(type) {203 case tea.KeyPressMsg:204 switch {205 case key.Matches(msg, r.common.KeyMap.Back):206 cmds = append(cmds, goBackCmd)207 }208 }209 case CopyMsg:210 txt := msg.Text211 if cfg := r.common.Config(); cfg != nil {212 cmds = append(cmds, tea.SetClipboard(txt))213 }214 r.statusbar.SetStatus("", msg.Message, "", "")215 case ReadmeMsg:216 cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))217 case FileItemsMsg, FileContentMsg:218 cmds = append(cmds, r.updateTabComponent(&Files{}, msg))219 case LogItemsMsg, LogDiffMsg, LogCountMsg:220 cmds = append(cmds, r.updateTabComponent(&Log{}, msg))221 case RefItemsMsg:222 cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))223 case StashListMsg, StashPatchMsg:224 cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))225 // We have two spinners, one is used to when loading the repository and the226 // other is used when loading the log.227 // Check if the spinner ID matches the spinner model.228 case spinner.TickMsg:229 if r.state == loadingState && r.spinner.ID() == msg.ID {230 s, cmd := r.spinner.Update(msg)231 r.spinner = s232 if cmd != nil {233 cmds = append(cmds, cmd)234 }235 } else {236 for i, c := range r.panes {237 if c.SpinnerID() == msg.ID {238 m, cmd := c.Update(msg)239 r.panes[i] = m.(common.TabComponent)240 if cmd != nil {241 cmds = append(cmds, cmd)242 }243 break244 }245 }246 }247 case tea.WindowSizeMsg:248 r.SetSize(msg.Width, msg.Height)249 cmds = append(cmds, r.updateModels(msg))250 case EmptyRepoMsg:251 r.ref = nil252 r.state = readyState253 cmds = append(cmds, r.updateModels(msg))254 case common.ErrorMsg:255 r.state = readyState256 case SwitchTabMsg:257 for i, c := range r.panes {258 if c.TabName() == msg.TabName() {259 cmds = append(cmds, tabs.SelectTabCmd(i))260 break261 }262 }263 }264 active := r.panes[r.activeTab]265 m, cmd := active.Update(msg)266 r.panes[r.activeTab] = m.(common.TabComponent)267 if cmd != nil {268 cmds = append(cmds, cmd)269 }270271 // Update the status bar on these events272 // Must come after we've updated the active tab273 switch msg.(type) {274 case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyPressMsg,275 tea.MouseClickMsg, tea.MouseWheelMsg, FileItemsMsg, FileContentMsg,276 FileBlameMsg, selector.ActiveMsg, LogItemsMsg, GoBackMsg, LogDiffMsg,277 EmptyRepoMsg, StashListMsg, StashPatchMsg:278 r.setStatusBarInfo()279 }280281 s, cmd := r.statusbar.Update(msg)282 r.statusbar = s.(*statusbar.Model)283 if cmd != nil {284 cmds = append(cmds, cmd)285 }286287 return r, tea.Batch(cmds...)288}289290// View implements tea.Model.291func (r *Repo) View() string {292 wm, hm := r.getMargins()293 hm += r.common.Styles.Tabs.GetHeight() +294 r.common.Styles.Tabs.GetVerticalFrameSize()295 s := r.common.Styles.Repo.Base.296 Width(r.common.Width - wm).297 Height(r.common.Height - hm)298 mainStyle := r.common.Styles.Repo.Body.299 Height(r.common.Height - hm)300 var main string301 var statusbar string302 switch r.state {303 case loadingState:304 main = fmt.Sprintf("%s loading…", r.spinner.View())305 case readyState:306 main = r.panes[r.activeTab].View()307 statusbar = r.statusbar.View()308 }309 main = r.common.Zone.Mark(310 "repo-main",311 mainStyle.Render(main),312 )313 view := lipgloss.JoinVertical(lipgloss.Left,314 r.headerView(),315 r.tabs.View(),316 main,317 statusbar,318 )319 return s.Render(view)320}321322func (r *Repo) headerView() string {323 if r.selectedRepo == nil {324 return ""325 }326 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)327 header := r.selectedRepo.ProjectName()328 if header == "" {329 header = r.selectedRepo.Name()330 }331 header = r.common.Styles.Repo.HeaderName.Render(header)332 desc := strings.TrimSpace(r.selectedRepo.Description())333 if desc != "" {334 header = lipgloss.JoinVertical(lipgloss.Left,335 header,336 r.common.Styles.Repo.HeaderDesc.Render(desc),337 )338 }339 urlStyle := r.common.Styles.URLStyle.340 Width(r.common.Width - lipgloss.Width(header) - 1).341 Align(lipgloss.Right)342 var url string343 if cfg := r.common.Config(); cfg != nil {344 url = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())345 }346 url = common.TruncateString(url, r.common.Width-lipgloss.Width(header)-1)347 url = r.common.Zone.Mark(348 fmt.Sprintf("%s-url", r.selectedRepo.Name()),349 urlStyle.Render(url),350 )351352 header = lipgloss.JoinHorizontal(lipgloss.Top, header, url)353354 style := r.common.Styles.Repo.Header.Width(r.common.Width)355 return style.Render(356 truncate.Render(header),357 )358}359360func (r *Repo) setStatusBarInfo() {361 if r.selectedRepo == nil {362 return363 }364365 active := r.panes[r.activeTab]366 key := r.selectedRepo.Name()367 value := active.StatusBarValue()368 info := active.StatusBarInfo()369 extra := "*"370 if r.ref != nil {371 extra += " " + r.ref.Name().Short()372 }373374 r.statusbar.SetStatus(key, value, info, extra)375}376377func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {378 cmds := make([]tea.Cmd, 0)379 for i, b := range r.panes {380 if b.TabName() == c.TabName() {381 m, cmd := b.Update(msg)382 r.panes[i] = m.(common.TabComponent)383 if cmd != nil {384 cmds = append(cmds, cmd)385 }386 break387 }388 }389 return tea.Batch(cmds...)390}391392func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {393 cmds := make([]tea.Cmd, 0)394 for i, b := range r.panes {395 m, cmd := b.Update(msg)396 r.panes[i] = m.(common.TabComponent)397 if cmd != nil {398 cmds = append(cmds, cmd)399 }400 }401 return tea.Batch(cmds...)402}403404func copyCmd(text, msg string) tea.Cmd {405 return func() tea.Msg {406 return CopyMsg{407 Text: text,408 Message: msg,409 }410 }411}412413func goBackCmd() tea.Msg {414 return GoBackMsg{}415}416417func switchTabCmd(m common.TabComponent) tea.Cmd {418 return func() tea.Msg {419 return SwitchTabMsg(m)420 }421}422423func renderLoading(c common.Common, s spinner.Model) string {424 msg := fmt.Sprintf("%s loading…", s.View())425 return c.Styles.SpinnerContainer.426 Height(c.Height).427 Render(msg)428}