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 "github.com/charmbracelet/soft-serve/config"
13 ggit "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/ui/common"
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.GitRepo
59
60// RefMsg is a message that contains a git.Reference.
61type RefMsg *ggit.Reference
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 cfg *config.Config
70 selectedRepo git.GitRepo
71 activeTab tab
72 tabs *tabs.Tabs
73 statusbar *statusbar.StatusBar
74 panes []common.Component
75 ref *ggit.Reference
76 copyURL time.Time
77}
78
79// New returns a new Repo.
80func New(cfg *config.Config, c common.Common) *Repo {
81 sb := statusbar.New(c)
82 ts := make([]string, lastTab)
83 // Tabs must match the order of tab constants above.
84 for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
85 ts[i] = t.String()
86 }
87 tb := tabs.New(c, ts)
88 readme := NewReadme(c)
89 log := NewLog(c)
90 files := NewFiles(c)
91 branches := NewRefs(c, ggit.RefsHeads)
92 tags := NewRefs(c, ggit.RefsTags)
93 // Make sure the order matches the order of tab constants above.
94 panes := []common.Component{
95 readme,
96 files,
97 log,
98 branches,
99 tags,
100 }
101 r := &Repo{
102 cfg: cfg,
103 common: c,
104 tabs: tb,
105 statusbar: sb,
106 panes: panes,
107 }
108 return r
109}
110
111// SetSize implements common.Component.
112func (r *Repo) SetSize(width, height int) {
113 r.common.SetSize(width, height)
114 hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
115 r.common.Styles.Repo.Header.GetHeight() +
116 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
117 r.common.Styles.StatusBar.GetHeight()
118 r.tabs.SetSize(width, height-hm)
119 r.statusbar.SetSize(width, height-hm)
120 for _, p := range r.panes {
121 p.SetSize(width, height-hm)
122 }
123}
124
125func (r *Repo) commonHelp() []key.Binding {
126 b := make([]key.Binding, 0)
127 back := r.common.KeyMap.Back
128 back.SetHelp("esc", "back to menu")
129 tab := r.common.KeyMap.Section
130 tab.SetHelp("tab", "switch tab")
131 b = append(b, back)
132 b = append(b, tab)
133 return b
134}
135
136// ShortHelp implements help.KeyMap.
137func (r *Repo) ShortHelp() []key.Binding {
138 b := r.commonHelp()
139 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
140 return b
141}
142
143// FullHelp implements help.KeyMap.
144func (r *Repo) FullHelp() [][]key.Binding {
145 b := make([][]key.Binding, 0)
146 b = append(b, r.commonHelp())
147 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
148 return b
149}
150
151// Init implements tea.View.
152func (r *Repo) Init() tea.Cmd {
153 return tea.Batch(
154 r.tabs.Init(),
155 r.statusbar.Init(),
156 )
157}
158
159// Update implements tea.Model.
160func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
161 cmds := make([]tea.Cmd, 0)
162 switch msg := msg.(type) {
163 case RepoMsg:
164 r.activeTab = 0
165 r.selectedRepo = git.GitRepo(msg)
166 cmds = append(cmds,
167 r.tabs.Init(),
168 r.updateRefCmd,
169 r.updateModels(msg),
170 )
171 case RefMsg:
172 r.ref = msg
173 for _, p := range r.panes {
174 cmds = append(cmds, p.Init())
175 }
176 cmds = append(cmds,
177 r.updateStatusBarCmd,
178 r.updateModels(msg),
179 )
180 case tabs.SelectTabMsg:
181 r.activeTab = tab(msg)
182 t, cmd := r.tabs.Update(msg)
183 r.tabs = t.(*tabs.Tabs)
184 if cmd != nil {
185 cmds = append(cmds, cmd)
186 }
187 case tabs.ActiveTabMsg:
188 r.activeTab = tab(msg)
189 if r.selectedRepo != nil {
190 cmds = append(cmds,
191 r.updateStatusBarCmd,
192 )
193 }
194 case tea.KeyMsg, tea.MouseMsg:
195 t, cmd := r.tabs.Update(msg)
196 r.tabs = t.(*tabs.Tabs)
197 if cmd != nil {
198 cmds = append(cmds, cmd)
199 }
200 if r.selectedRepo != nil {
201 cmds = append(cmds, r.updateStatusBarCmd)
202 switch msg := msg.(type) {
203 case tea.MouseMsg:
204 switch msg.Type {
205 case tea.MouseLeft:
206 id := fmt.Sprintf("%s-url", r.selectedRepo.Repo())
207 if r.common.Zone.Get(id).InBounds(msg) {
208 cmds = append(cmds, r.copyUrlCmd())
209 }
210 case tea.MouseRight:
211 if r.common.Zone.Get("repo-main").InBounds(msg) {
212 cmds = append(cmds, backCmd)
213 }
214 }
215 }
216 }
217 case CopyURLMsg:
218 r.common.Copy.Copy(
219 git.RepoURL(r.cfg.Host, r.cfg.Port, r.selectedRepo.Repo()),
220 )
221 case ResetURLMsg:
222 r.copyURL = time.Time{}
223 case ReadmeMsg:
224 case FileItemsMsg:
225 f, cmd := r.panes[filesTab].Update(msg)
226 r.panes[filesTab] = f.(*Files)
227 if cmd != nil {
228 cmds = append(cmds, cmd)
229 }
230 // The Log bubble is the only bubble that uses a spinner, so this is fine
231 // for now. We need to pass the TickMsg to the Log bubble when the Log is
232 // loading but not the current selected tab so that the spinner works.
233 case LogCountMsg, LogItemsMsg, spinner.TickMsg:
234 l, cmd := r.panes[commitsTab].Update(msg)
235 r.panes[commitsTab] = l.(*Log)
236 if cmd != nil {
237 cmds = append(cmds, cmd)
238 }
239 case RefItemsMsg:
240 switch msg.prefix {
241 case ggit.RefsHeads:
242 b, cmd := r.panes[branchesTab].Update(msg)
243 r.panes[branchesTab] = b.(*Refs)
244 if cmd != nil {
245 cmds = append(cmds, cmd)
246 }
247 case ggit.RefsTags:
248 t, cmd := r.panes[tagsTab].Update(msg)
249 r.panes[tagsTab] = t.(*Refs)
250 if cmd != nil {
251 cmds = append(cmds, cmd)
252 }
253 }
254 case UpdateStatusBarMsg:
255 cmds = append(cmds, r.updateStatusBarCmd)
256 case tea.WindowSizeMsg:
257 cmds = append(cmds, r.updateModels(msg))
258 }
259 s, cmd := r.statusbar.Update(msg)
260 r.statusbar = s.(*statusbar.StatusBar)
261 if cmd != nil {
262 cmds = append(cmds, cmd)
263 }
264 m, cmd := r.panes[r.activeTab].Update(msg)
265 r.panes[r.activeTab] = m.(common.Component)
266 if cmd != nil {
267 cmds = append(cmds, cmd)
268 }
269 return r, tea.Batch(cmds...)
270}
271
272// View implements tea.Model.
273func (r *Repo) View() string {
274 s := r.common.Styles.Repo.Base.Copy().
275 Width(r.common.Width).
276 Height(r.common.Height)
277 repoBodyStyle := r.common.Styles.Repo.Body.Copy()
278 hm := repoBodyStyle.GetVerticalFrameSize() +
279 r.common.Styles.Repo.Header.GetHeight() +
280 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
281 r.common.Styles.StatusBar.GetHeight() +
282 r.common.Styles.Tabs.GetHeight() +
283 r.common.Styles.Tabs.GetVerticalFrameSize()
284 mainStyle := repoBodyStyle.
285 Height(r.common.Height - hm)
286 main := r.common.Zone.Mark(
287 "repo-main",
288 r.panes[r.activeTab].View(),
289 )
290 view := lipgloss.JoinVertical(lipgloss.Top,
291 r.headerView(),
292 r.tabs.View(),
293 mainStyle.Render(main),
294 r.statusbar.View(),
295 )
296 return s.Render(view)
297}
298
299func (r *Repo) headerView() string {
300 if r.selectedRepo == nil {
301 return ""
302 }
303 cfg := r.cfg
304 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
305 name := r.common.Styles.Repo.HeaderName.Render(r.selectedRepo.Name())
306 desc := r.selectedRepo.Description()
307 if desc == "" {
308 desc = name
309 name = ""
310 } else {
311 desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
312 }
313 urlStyle := r.common.Styles.URLStyle.Copy().
314 Width(r.common.Width - lipgloss.Width(desc) - 1).
315 Align(lipgloss.Right)
316 url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
317 if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
318 url = "copied!"
319 }
320 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
321 url = r.common.Zone.Mark(
322 fmt.Sprintf("%s-url", r.selectedRepo.Repo()),
323 urlStyle.Render(url),
324 )
325 style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
326 return style.Render(
327 lipgloss.JoinVertical(lipgloss.Top,
328 truncate.Render(name),
329 truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
330 desc,
331 url,
332 )),
333 ),
334 )
335}
336
337func (r *Repo) updateStatusBarCmd() tea.Msg {
338 if r.selectedRepo == nil {
339 return nil
340 }
341 value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
342 info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
343 ref := ""
344 if r.ref != nil {
345 ref = r.ref.Name().Short()
346 }
347 return statusbar.StatusBarMsg{
348 Key: r.selectedRepo.Repo(),
349 Value: value,
350 Info: info,
351 Branch: fmt.Sprintf("* %s", ref),
352 }
353}
354
355func (r *Repo) updateRefCmd() tea.Msg {
356 if r.selectedRepo == nil {
357 return nil
358 }
359 head, err := r.selectedRepo.HEAD()
360 if err != nil {
361 return common.ErrorMsg(err)
362 }
363 return RefMsg(head)
364}
365
366func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
367 cmds := make([]tea.Cmd, 0)
368 for i, b := range r.panes {
369 m, cmd := b.Update(msg)
370 r.panes[i] = m.(common.Component)
371 if cmd != nil {
372 cmds = append(cmds, cmd)
373 }
374 }
375 return tea.Batch(cmds...)
376}
377
378func (r *Repo) copyUrlCmd() tea.Cmd {
379 r.copyURL = time.Now()
380 return tea.Batch(
381 func() tea.Msg {
382 return CopyURLMsg{}
383 },
384 tea.Tick(time.Second, func(time.Time) tea.Msg {
385 return ResetURLMsg{}
386 }),
387 )
388}
389
390func updateStatusBarCmd() tea.Msg {
391 return UpdateStatusBarMsg{}
392}
393
394func backCmd() tea.Msg {
395 return BackMsg{}
396}