1package repo
2
3import (
4 "fmt"
5 "strings"
6
7 "github.com/charmbracelet/bubbles/help"
8 "github.com/charmbracelet/bubbles/key"
9 "github.com/charmbracelet/bubbles/spinner"
10 tea "github.com/charmbracelet/bubbletea"
11 "github.com/charmbracelet/lipgloss"
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)
20
21type state int
22
23const (
24 loadingState state = iota
25 readyState
26)
27
28// EmptyRepoMsg is a message to indicate that the repository is empty.
29type EmptyRepoMsg struct{}
30
31// CopyURLMsg is a message to copy the URL of the current repository.
32type CopyURLMsg struct{}
33
34// RepoMsg is a message that contains a git.Repository.
35type RepoMsg proto.Repository // nolint:revive
36
37// GoBackMsg is a message to go back to the previous view.
38type GoBackMsg struct{}
39
40// CopyMsg is a message to indicate copied text.
41type CopyMsg struct {
42 Text string
43 Message string
44}
45
46// SwitchTabMsg is a message to switch tabs.
47type SwitchTabMsg common.TabComponent
48
49// Repo is a view for a git repository.
50type Repo struct {
51 common common.Common
52 selectedRepo proto.Repository
53 activeTab int
54 tabs *tabs.Tabs
55 statusbar *statusbar.Model
56 panes []common.TabComponent
57 ref *git.Reference
58 state state
59 spinner spinner.Model
60 panesReady []bool
61}
62
63// 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 r
85}
86
87func (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, hm
94}
95
96// 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}
106
107func (r *Repo) commonHelp() []key.Binding {
108 b := make([]key.Binding, 0)
109 back := r.common.KeyMap.Back
110 back.SetHelp("esc", "back to menu")
111 tab := r.common.KeyMap.Section
112 tab.SetHelp("tab", "switch tab")
113 b = append(b, back)
114 b = append(b, tab)
115 return b
116}
117
118// ShortHelp implements help.KeyMap.
119func (r *Repo) ShortHelp() []key.Binding {
120 b := r.commonHelp()
121 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
122 return b
123}
124
125// FullHelp implements help.KeyMap.
126func (r *Repo) FullHelp() [][]key.Binding {
127 b := make([][]key.Binding, 0)
128 b = append(b, r.commonHelp())
129 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
130 return b
131}
132
133// Init implements tea.View.
134func (r *Repo) Init() tea.Cmd {
135 r.state = loadingState
136 r.activeTab = 0
137 return tea.Batch(
138 r.tabs.Init(),
139 r.statusbar.Init(),
140 r.spinner.Tick,
141 )
142}
143
144// Update implements tea.Model.
145func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146 cmds := make([]tea.Cmd, 0)
147 switch msg := msg.(type) {
148 case RepoMsg:
149 // Set the state to loading when we get a new repository.
150 r.selectedRepo = msg
151 cmds = append(cmds,
152 r.Init(),
153 // This will set the selected repo in each pane's model.
154 r.updateModels(msg),
155 )
156 case RefMsg:
157 r.ref = msg
158 cmds = append(cmds, r.updateModels(msg))
159 r.state = readyState
160 case tabs.SelectTabMsg:
161 r.activeTab = int(msg)
162 t, cmd := r.tabs.Update(msg)
163 r.tabs = t.(*tabs.Tabs)
164 if cmd != nil {
165 cmds = append(cmds, cmd)
166 }
167 case tabs.ActiveTabMsg:
168 r.activeTab = int(msg)
169 case tea.KeyMsg, tea.MouseMsg:
170 t, cmd := r.tabs.Update(msg)
171 r.tabs = t.(*tabs.Tabs)
172 if cmd != nil {
173 cmds = append(cmds, cmd)
174 }
175 if r.selectedRepo != nil {
176 urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
177 cmd := r.common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
178 if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
179 cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))
180 }
181 }
182 switch msg := msg.(type) {
183 case tea.MouseMsg:
184 switch msg.Type {
185 case tea.MouseLeft:
186 switch {
187 case r.common.Zone.Get("repo-help").InBounds(msg):
188 cmds = append(cmds, footer.ToggleFooterCmd)
189 }
190 case tea.MouseRight:
191 switch {
192 case r.common.Zone.Get("repo-main").InBounds(msg):
193 cmds = append(cmds, goBackCmd)
194 }
195 }
196 }
197 case CopyMsg:
198 txt := msg.Text
199 if cfg := r.common.Config(); cfg != nil {
200 r.common.Output.Copy(txt)
201 }
202 r.statusbar.SetStatus("", msg.Message, "", "")
203 case ReadmeMsg:
204 cmds = append(cmds, r.updateTabComponent(&Readme{}, msg))
205 case FileItemsMsg, FileContentMsg:
206 cmds = append(cmds, r.updateTabComponent(&Files{}, msg))
207 case LogItemsMsg, LogDiffMsg, LogCountMsg:
208 cmds = append(cmds, r.updateTabComponent(&Log{}, msg))
209 case RefItemsMsg:
210 cmds = append(cmds, r.updateTabComponent(&Refs{refPrefix: msg.prefix}, msg))
211 case StashListMsg, StashPatchMsg:
212 cmds = append(cmds, r.updateTabComponent(&Stash{}, msg))
213 // We have two spinners, one is used to when loading the repository and the
214 // other is used when loading the log.
215 // Check if the spinner ID matches the spinner model.
216 case spinner.TickMsg:
217 if r.state == loadingState && r.spinner.ID() == msg.ID {
218 s, cmd := r.spinner.Update(msg)
219 r.spinner = s
220 if cmd != nil {
221 cmds = append(cmds, cmd)
222 }
223 } else {
224 for i, c := range r.panes {
225 if c.SpinnerID() == msg.ID {
226 m, cmd := c.Update(msg)
227 r.panes[i] = m.(common.TabComponent)
228 if cmd != nil {
229 cmds = append(cmds, cmd)
230 }
231 break
232 }
233 }
234 }
235 case tea.WindowSizeMsg:
236 r.SetSize(msg.Width, msg.Height)
237 cmds = append(cmds, r.updateModels(msg))
238 case EmptyRepoMsg:
239 r.ref = nil
240 r.state = readyState
241 cmds = append(cmds, r.updateModels(msg))
242 case common.ErrorMsg:
243 r.state = readyState
244 case SwitchTabMsg:
245 for i, c := range r.panes {
246 if c.TabName() == msg.TabName() {
247 cmds = append(cmds, tabs.SelectTabCmd(i))
248 break
249 }
250 }
251 }
252 active := r.panes[r.activeTab]
253 m, cmd := active.Update(msg)
254 r.panes[r.activeTab] = m.(common.TabComponent)
255 if cmd != nil {
256 cmds = append(cmds, cmd)
257 }
258
259 // Update the status bar on these events
260 // Must come after we've updated the active tab
261 switch msg.(type) {
262 case RepoMsg, RefMsg, tabs.ActiveTabMsg, tea.KeyMsg, tea.MouseMsg,
263 FileItemsMsg, FileContentMsg, FileBlameMsg, selector.ActiveMsg,
264 LogItemsMsg, GoBackMsg, LogDiffMsg, EmptyRepoMsg,
265 StashListMsg, StashPatchMsg:
266 r.setStatusBarInfo()
267 }
268
269 s, cmd := r.statusbar.Update(msg)
270 r.statusbar = s.(*statusbar.Model)
271 if cmd != nil {
272 cmds = append(cmds, cmd)
273 }
274
275 return r, tea.Batch(cmds...)
276}
277
278// View implements tea.Model.
279func (r *Repo) View() string {
280 wm, hm := r.getMargins()
281 hm += r.common.Styles.Tabs.GetHeight() +
282 r.common.Styles.Tabs.GetVerticalFrameSize()
283 s := r.common.Styles.Repo.Base.Copy().
284 Width(r.common.Width - wm).
285 Height(r.common.Height - hm)
286 mainStyle := r.common.Styles.Repo.Body.Copy().
287 Height(r.common.Height - hm)
288 var main string
289 var statusbar string
290 switch r.state {
291 case loadingState:
292 main = fmt.Sprintf("%s loading…", r.spinner.View())
293 case readyState:
294 main = r.panes[r.activeTab].View()
295 statusbar = r.statusbar.View()
296 }
297 main = r.common.Zone.Mark(
298 "repo-main",
299 mainStyle.Render(main),
300 )
301 view := lipgloss.JoinVertical(lipgloss.Top,
302 r.headerView(),
303 r.tabs.View(),
304 main,
305 statusbar,
306 )
307 return s.Render(view)
308}
309
310func (r *Repo) headerView() string {
311 if r.selectedRepo == nil {
312 return ""
313 }
314 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
315 header := r.selectedRepo.ProjectName()
316 if header == "" {
317 header = r.selectedRepo.Name()
318 }
319 header = r.common.Styles.Repo.HeaderName.Render(header)
320 desc := strings.TrimSpace(r.selectedRepo.Description())
321 if desc != "" {
322 header = lipgloss.JoinVertical(lipgloss.Top,
323 header,
324 r.common.Styles.Repo.HeaderDesc.Render(desc),
325 )
326 }
327 urlStyle := r.common.Styles.URLStyle.Copy().
328 Width(r.common.Width - lipgloss.Width(desc) - 1).
329 Align(lipgloss.Right)
330 var url string
331 if cfg := r.common.Config(); cfg != nil {
332 url = r.common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
333 }
334 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
335 url = r.common.Zone.Mark(
336 fmt.Sprintf("%s-url", r.selectedRepo.Name()),
337 urlStyle.Render(url),
338 )
339
340 header = lipgloss.JoinHorizontal(lipgloss.Left, header, url)
341
342 style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
343 return style.Render(
344 truncate.Render(header),
345 )
346}
347
348func (r *Repo) setStatusBarInfo() {
349 if r.selectedRepo == nil {
350 return
351 }
352
353 active := r.panes[r.activeTab]
354 key := r.selectedRepo.Name()
355 value := active.StatusBarValue()
356 info := active.StatusBarInfo()
357 extra := "*"
358 if r.ref != nil {
359 extra += " " + r.ref.Name().Short()
360 }
361
362 r.statusbar.SetStatus(key, value, info, extra)
363}
364
365func (r *Repo) updateTabComponent(c common.TabComponent, msg tea.Msg) tea.Cmd {
366 cmds := make([]tea.Cmd, 0)
367 for i, b := range r.panes {
368 if b.TabName() == c.TabName() {
369 m, cmd := b.Update(msg)
370 r.panes[i] = m.(common.TabComponent)
371 if cmd != nil {
372 cmds = append(cmds, cmd)
373 }
374 break
375 }
376 }
377 return tea.Batch(cmds...)
378}
379
380func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
381 cmds := make([]tea.Cmd, 0)
382 for i, b := range r.panes {
383 m, cmd := b.Update(msg)
384 r.panes[i] = m.(common.TabComponent)
385 if cmd != nil {
386 cmds = append(cmds, cmd)
387 }
388 }
389 return tea.Batch(cmds...)
390}
391
392func copyCmd(text, msg string) tea.Cmd {
393 return func() tea.Msg {
394 return CopyMsg{
395 Text: text,
396 Message: msg,
397 }
398 }
399}
400
401func goBackCmd() tea.Msg {
402 return GoBackMsg{}
403}
404
405func switchTabCmd(m common.TabComponent) tea.Cmd {
406 return func() tea.Msg {
407 return SwitchTabMsg(m)
408 }
409}
410
411func renderLoading(c common.Common, s spinner.Model) string {
412 msg := fmt.Sprintf("%s loading…", s.View())
413 return c.Styles.SpinnerContainer.Copy().
414 Height(c.Height).
415 Render(msg)
416}