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