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