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