1package repo
2
3import (
4 "fmt"
5 "time"
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 ggit "github.com/charmbracelet/soft-serve/git"
13 "github.com/charmbracelet/soft-serve/ui/common"
14 "github.com/charmbracelet/soft-serve/ui/components/footer"
15 "github.com/charmbracelet/soft-serve/ui/components/statusbar"
16 "github.com/charmbracelet/soft-serve/ui/components/tabs"
17 "github.com/charmbracelet/soft-serve/ui/git"
18)
19
20type state int
21
22const (
23 loadingState state = iota
24 readyState
25)
26
27type tab int
28
29const (
30 readmeTab tab = iota
31 filesTab
32 commitsTab
33 branchesTab
34 tagsTab
35 lastTab
36)
37
38func (t tab) String() string {
39 return []string{
40 "Readme",
41 "Files",
42 "Commits",
43 "Branches",
44 "Tags",
45 }[t]
46}
47
48// EmptyRepoMsg is a message to indicate that the repository is empty.
49type EmptyRepoMsg struct{}
50
51// CopyURLMsg is a message to copy the URL of the current repository.
52type CopyURLMsg struct{}
53
54// ResetURLMsg is a message to reset the URL string.
55type ResetURLMsg struct{}
56
57// UpdateStatusBarMsg updates the status bar.
58type UpdateStatusBarMsg struct{}
59
60// RepoMsg is a message that contains a git.Repository.
61type RepoMsg *git.Repository
62
63// BackMsg is a message to go back to the previous view.
64type BackMsg struct{}
65
66// Repo is a view for a git repository.
67type Repo struct {
68 common common.Common
69 selectedRepo *git.Repository
70 activeTab tab
71 tabs *tabs.Tabs
72 statusbar *statusbar.StatusBar
73 panes []common.Component
74 ref *ggit.Reference
75 copyURL time.Time
76 state state
77 spinner spinner.Model
78 panesReady [lastTab]bool
79}
80
81// New returns a new Repo.
82func New(c common.Common) *Repo {
83 sb := statusbar.New(c)
84 ts := make([]string, lastTab)
85 // Tabs must match the order of tab constants above.
86 for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
87 ts[i] = t.String()
88 }
89 tb := tabs.New(c, ts)
90 readme := NewReadme(c)
91 log := NewLog(c)
92 files := NewFiles(c)
93 branches := NewRefs(c, ggit.RefsHeads)
94 tags := NewRefs(c, ggit.RefsTags)
95 // Make sure the order matches the order of tab constants above.
96 panes := []common.Component{
97 readme,
98 files,
99 log,
100 branches,
101 tags,
102 }
103 s := spinner.New(spinner.WithSpinner(spinner.Dot),
104 spinner.WithStyle(c.Styles.Spinner))
105 r := &Repo{
106 common: c,
107 tabs: tb,
108 statusbar: sb,
109 panes: panes,
110 state: loadingState,
111 spinner: s,
112 }
113 return r
114}
115
116// SetSize implements common.Component.
117func (r *Repo) SetSize(width, height int) {
118 r.common.SetSize(width, height)
119 hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
120 r.common.Styles.Repo.Header.GetHeight() +
121 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
122 r.common.Styles.StatusBar.GetHeight()
123 r.tabs.SetSize(width, height-hm)
124 r.statusbar.SetSize(width, height-hm)
125 for _, p := range r.panes {
126 p.SetSize(width, height-hm)
127 }
128}
129
130func (r *Repo) commonHelp() []key.Binding {
131 b := make([]key.Binding, 0)
132 back := r.common.KeyMap.Back
133 back.SetHelp("esc", "back to menu")
134 tab := r.common.KeyMap.Section
135 tab.SetHelp("tab", "switch tab")
136 b = append(b, back)
137 b = append(b, tab)
138 return b
139}
140
141// ShortHelp implements help.KeyMap.
142func (r *Repo) ShortHelp() []key.Binding {
143 b := r.commonHelp()
144 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
145 return b
146}
147
148// FullHelp implements help.KeyMap.
149func (r *Repo) FullHelp() [][]key.Binding {
150 b := make([][]key.Binding, 0)
151 b = append(b, r.commonHelp())
152 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
153 return b
154}
155
156// Init implements tea.View.
157func (r *Repo) Init() tea.Cmd {
158 return tea.Batch(
159 r.tabs.Init(),
160 r.statusbar.Init(),
161 )
162}
163
164// Update implements tea.Model.
165func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
166 cmds := make([]tea.Cmd, 0)
167 switch msg := msg.(type) {
168 case RepoMsg:
169 // Set the state to loading when we get a new repository.
170 r.state = loadingState
171 r.panesReady = [lastTab]bool{}
172 r.activeTab = 0
173 r.selectedRepo = msg
174 cmds = append(cmds,
175 r.tabs.Init(),
176 // This will set the selected repo in each pane's model.
177 r.updateModels(msg),
178 )
179 case RefMsg:
180 r.ref = msg
181 for _, p := range r.panes {
182 // Init will initiate each pane's model with its contents.
183 cmds = append(cmds, p.Init())
184 }
185 cmds = append(cmds,
186 r.updateStatusBarCmd,
187 r.updateModels(msg),
188 )
189 case tabs.SelectTabMsg:
190 r.activeTab = tab(msg)
191 t, cmd := r.tabs.Update(msg)
192 r.tabs = t.(*tabs.Tabs)
193 if cmd != nil {
194 cmds = append(cmds, cmd)
195 }
196 case tabs.ActiveTabMsg:
197 r.activeTab = tab(msg)
198 if r.selectedRepo != nil {
199 cmds = append(cmds,
200 r.updateStatusBarCmd,
201 )
202 }
203 case tea.KeyMsg, tea.MouseMsg:
204 t, cmd := r.tabs.Update(msg)
205 r.tabs = t.(*tabs.Tabs)
206 if cmd != nil {
207 cmds = append(cmds, cmd)
208 }
209 if r.selectedRepo != nil {
210 cmds = append(cmds, r.updateStatusBarCmd)
211 urlID := fmt.Sprintf("%s-url", r.selectedRepo.Info.Name())
212 if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
213 cmds = append(cmds, r.copyURLCmd())
214 }
215 }
216 switch msg := msg.(type) {
217 case tea.MouseMsg:
218 switch msg.Type {
219 case tea.MouseLeft:
220 switch {
221 case r.common.Zone.Get("repo-help").InBounds(msg):
222 cmds = append(cmds, footer.ToggleFooterCmd)
223 }
224 case tea.MouseRight:
225 switch {
226 case r.common.Zone.Get("repo-main").InBounds(msg):
227 cmds = append(cmds, backCmd)
228 }
229 }
230 }
231 case CopyURLMsg:
232 if cfg := r.common.Config(); cfg != nil {
233 host := cfg.Host
234 port := cfg.SSH.Port
235 r.common.Copy.Copy(
236 git.RepoURL(host, port, r.selectedRepo.Info.Name()),
237 )
238 }
239 case ResetURLMsg:
240 r.copyURL = time.Time{}
241 case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
242 cmds = append(cmds, r.updateRepo(msg))
243 // We have two spinners, one is used to when loading the repository and the
244 // other is used when loading the log.
245 // Check if the spinner ID matches the spinner model.
246 case spinner.TickMsg:
247 switch msg.ID {
248 case r.spinner.ID():
249 if r.state == loadingState {
250 s, cmd := r.spinner.Update(msg)
251 r.spinner = s
252 if cmd != nil {
253 cmds = append(cmds, cmd)
254 }
255 }
256 default:
257 cmds = append(cmds, r.updateRepo(msg))
258 }
259 case UpdateStatusBarMsg:
260 cmds = append(cmds, r.updateStatusBarCmd)
261 case tea.WindowSizeMsg:
262 cmds = append(cmds, r.updateModels(msg))
263 case EmptyRepoMsg:
264 r.state = readyState
265 cmds = append(cmds, r.updateStatusBarCmd)
266 case common.ErrorMsg:
267 r.state = readyState
268 }
269 s, cmd := r.statusbar.Update(msg)
270 r.statusbar = s.(*statusbar.StatusBar)
271 if cmd != nil {
272 cmds = append(cmds, cmd)
273 }
274 m, cmd := r.panes[r.activeTab].Update(msg)
275 r.panes[r.activeTab] = m.(common.Component)
276 if cmd != nil {
277 cmds = append(cmds, cmd)
278 }
279 return r, tea.Batch(cmds...)
280}
281
282// View implements tea.Model.
283func (r *Repo) View() string {
284 s := r.common.Styles.Repo.Base.Copy().
285 Width(r.common.Width).
286 Height(r.common.Height)
287 repoBodyStyle := r.common.Styles.Repo.Body.Copy()
288 hm := repoBodyStyle.GetVerticalFrameSize() +
289 r.common.Styles.Repo.Header.GetHeight() +
290 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
291 r.common.Styles.StatusBar.GetHeight() +
292 r.common.Styles.Tabs.GetHeight() +
293 r.common.Styles.Tabs.GetVerticalFrameSize()
294 mainStyle := repoBodyStyle.
295 Height(r.common.Height - hm)
296 var main string
297 var statusbar string
298 switch r.state {
299 case loadingState:
300 main = fmt.Sprintf("%s loading…", r.spinner.View())
301 case readyState:
302 main = r.panes[r.activeTab].View()
303 statusbar = r.statusbar.View()
304 }
305 main = r.common.Zone.Mark(
306 "repo-main",
307 mainStyle.Render(main),
308 )
309 view := lipgloss.JoinVertical(lipgloss.Top,
310 r.headerView(),
311 r.tabs.View(),
312 main,
313 statusbar,
314 )
315 return s.Render(view)
316}
317
318func (r *Repo) headerView() string {
319 if r.selectedRepo == nil {
320 return ""
321 }
322 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
323 name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Info.Name())
324 desc := r.selectedRepo.Info.Description()
325 if desc == "" {
326 desc = name
327 name = ""
328 } else {
329 desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
330 }
331 urlStyle := r.common.Styles.URLStyle.Copy().
332 Width(r.common.Width - lipgloss.Width(desc) - 1).
333 Align(lipgloss.Right)
334 var url string
335 if cfg := r.common.Config(); cfg != nil {
336 url = git.RepoURL(cfg.Host, cfg.SSH.Port, r.selectedRepo.Info.Name())
337 }
338 if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
339 url = "copied!"
340 }
341 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
342 url = r.common.Zone.Mark(
343 fmt.Sprintf("%s-url", r.selectedRepo.Info.Name()),
344 urlStyle.Render(url),
345 )
346 style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
347 return style.Render(
348 lipgloss.JoinVertical(lipgloss.Top,
349 truncate.Render(name),
350 truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
351 desc,
352 url,
353 )),
354 ),
355 )
356}
357
358func (r *Repo) updateStatusBarCmd() tea.Msg {
359 if r.selectedRepo == nil {
360 return nil
361 }
362 value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
363 info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
364 branch := "*"
365 if r.ref != nil {
366 branch += " " + r.ref.Name().Short()
367 }
368 return statusbar.StatusBarMsg{
369 Key: r.selectedRepo.Info.Name(),
370 Value: value,
371 Info: info,
372 Branch: branch,
373 }
374}
375
376func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
377 cmds := make([]tea.Cmd, 0)
378 for i, b := range r.panes {
379 m, cmd := b.Update(msg)
380 r.panes[i] = m.(common.Component)
381 if cmd != nil {
382 cmds = append(cmds, cmd)
383 }
384 }
385 return tea.Batch(cmds...)
386}
387
388func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
389 cmds := make([]tea.Cmd, 0)
390 switch msg := msg.(type) {
391 case LogCountMsg, LogItemsMsg, spinner.TickMsg:
392 switch msg.(type) {
393 case LogItemsMsg:
394 r.panesReady[commitsTab] = true
395 }
396 l, cmd := r.panes[commitsTab].Update(msg)
397 r.panes[commitsTab] = l.(*Log)
398 if cmd != nil {
399 cmds = append(cmds, cmd)
400 }
401 case FileItemsMsg:
402 r.panesReady[filesTab] = true
403 f, cmd := r.panes[filesTab].Update(msg)
404 r.panes[filesTab] = f.(*Files)
405 if cmd != nil {
406 cmds = append(cmds, cmd)
407 }
408 case RefItemsMsg:
409 switch msg.prefix {
410 case ggit.RefsHeads:
411 r.panesReady[branchesTab] = true
412 b, cmd := r.panes[branchesTab].Update(msg)
413 r.panes[branchesTab] = b.(*Refs)
414 if cmd != nil {
415 cmds = append(cmds, cmd)
416 }
417 case ggit.RefsTags:
418 r.panesReady[tagsTab] = true
419 t, cmd := r.panes[tagsTab].Update(msg)
420 r.panes[tagsTab] = t.(*Refs)
421 if cmd != nil {
422 cmds = append(cmds, cmd)
423 }
424 }
425 case ReadmeMsg:
426 r.panesReady[readmeTab] = true
427 }
428 if r.isReady() {
429 r.state = readyState
430 }
431 return tea.Batch(cmds...)
432}
433
434func (r *Repo) isReady() bool {
435 ready := true
436 // We purposely ignore the log pane here because it has its own spinner.
437 for _, b := range []bool{
438 r.panesReady[filesTab], r.panesReady[branchesTab],
439 r.panesReady[tagsTab], r.panesReady[readmeTab],
440 } {
441 if !b {
442 ready = false
443 break
444 }
445 }
446 return ready
447}
448
449func (r *Repo) copyURLCmd() tea.Cmd {
450 r.copyURL = time.Now()
451 return tea.Batch(
452 func() tea.Msg {
453 return CopyURLMsg{}
454 },
455 tea.Tick(time.Second, func(time.Time) tea.Msg {
456 return ResetURLMsg{}
457 }),
458 )
459}
460
461func updateStatusBarCmd() tea.Msg {
462 return UpdateStatusBarMsg{}
463}
464
465func backCmd() tea.Msg {
466 return BackMsg{}
467}