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