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 // TODO move this into a style.
314 urlStyle := lipgloss.NewStyle().
315 MarginLeft(1).
316 Foreground(lipgloss.Color("168")).
317 Width(r.common.Width - lipgloss.Width(desc) - 1).
318 Align(lipgloss.Right)
319 url := git.RepoURL(cfg.Host, cfg.Port, r.selectedRepo.Repo())
320 if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
321 url = "copied!"
322 }
323 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
324 url = r.common.Zone.Mark(
325 fmt.Sprintf("%s-url", r.selectedRepo.Repo()),
326 urlStyle.Render(url),
327 )
328 style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
329 return style.Render(
330 lipgloss.JoinVertical(lipgloss.Top,
331 truncate.Render(name),
332 truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
333 desc,
334 url,
335 )),
336 ),
337 )
338}
339
340func (r *Repo) updateStatusBarCmd() tea.Msg {
341 if r.selectedRepo == nil {
342 return nil
343 }
344 value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
345 info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
346 ref := ""
347 if r.ref != nil {
348 ref = r.ref.Name().Short()
349 }
350 return statusbar.StatusBarMsg{
351 Key: r.selectedRepo.Repo(),
352 Value: value,
353 Info: info,
354 Branch: fmt.Sprintf("* %s", ref),
355 }
356}
357
358func (r *Repo) updateRefCmd() tea.Msg {
359 if r.selectedRepo == nil {
360 return nil
361 }
362 head, err := r.selectedRepo.HEAD()
363 if err != nil {
364 return common.ErrorMsg(err)
365 }
366 return RefMsg(head)
367}
368
369func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
370 cmds := make([]tea.Cmd, 0)
371 for i, b := range r.panes {
372 m, cmd := b.Update(msg)
373 r.panes[i] = m.(common.Component)
374 if cmd != nil {
375 cmds = append(cmds, cmd)
376 }
377 }
378 return tea.Batch(cmds...)
379}
380
381func (r *Repo) copyUrlCmd() tea.Cmd {
382 r.copyURL = time.Now()
383 return tea.Batch(
384 func() tea.Msg {
385 return CopyURLMsg{}
386 },
387 tea.Tick(time.Second, func(time.Time) tea.Msg {
388 return ResetURLMsg{}
389 }),
390 )
391}
392
393func updateStatusBarCmd() tea.Msg {
394 return UpdateStatusBarMsg{}
395}
396
397func backCmd() tea.Msg {
398 return BackMsg{}
399}