1package repo
2
3import (
4 "fmt"
5
6 "github.com/charmbracelet/bubbles/help"
7 "github.com/charmbracelet/bubbles/key"
8 "github.com/charmbracelet/bubbles/spinner"
9 tea "github.com/charmbracelet/bubbletea"
10 "github.com/charmbracelet/lipgloss"
11 "github.com/charmbracelet/soft-serve/git"
12 "github.com/charmbracelet/soft-serve/server/backend"
13 "github.com/charmbracelet/soft-serve/ui/common"
14 "github.com/charmbracelet/soft-serve/ui/components/footer"
15 "github.com/charmbracelet/soft-serve/ui/components/statusbar"
16 "github.com/charmbracelet/soft-serve/ui/components/tabs"
17)
18
19type state int
20
21const (
22 loadingState state = iota
23 readyState
24)
25
26type tab int
27
28const (
29 readmeTab tab = iota
30 filesTab
31 commitsTab
32 branchesTab
33 tagsTab
34 lastTab
35)
36
37func (t tab) String() string {
38 return []string{
39 "Readme",
40 "Files",
41 "Commits",
42 "Branches",
43 "Tags",
44 }[t]
45}
46
47// EmptyRepoMsg is a message to indicate that the repository is empty.
48type EmptyRepoMsg struct{}
49
50// CopyURLMsg is a message to copy the URL of the current repository.
51type CopyURLMsg struct{}
52
53// UpdateStatusBarMsg updates the status bar.
54type UpdateStatusBarMsg struct{}
55
56// RepoMsg is a message that contains a git.Repository.
57type RepoMsg backend.Repository
58
59// BackMsg is a message to go back to the previous view.
60type BackMsg struct{}
61
62// CopyMsg is a message to indicate copied text.
63type CopyMsg struct {
64 Text string
65 Message string
66}
67
68// Repo is a view for a git repository.
69type Repo struct {
70 common common.Common
71 selectedRepo backend.Repository
72 activeTab tab
73 tabs *tabs.Tabs
74 statusbar *statusbar.StatusBar
75 panes []common.Component
76 ref *git.Reference
77 state state
78 spinner spinner.Model
79 panesReady [lastTab]bool
80}
81
82// New returns a new Repo.
83func New(c common.Common) *Repo {
84 sb := statusbar.New(c)
85 ts := make([]string, lastTab)
86 // Tabs must match the order of tab constants above.
87 for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
88 ts[i] = t.String()
89 }
90 c.Logger = c.Logger.WithPrefix("ui.repo")
91 tb := tabs.New(c, ts)
92 readme := NewReadme(c)
93 log := NewLog(c)
94 files := NewFiles(c)
95 branches := NewRefs(c, git.RefsHeads)
96 tags := NewRefs(c, git.RefsTags)
97 // Make sure the order matches the order of tab constants above.
98 panes := []common.Component{
99 readme,
100 files,
101 log,
102 branches,
103 tags,
104 }
105 s := spinner.New(spinner.WithSpinner(spinner.Dot),
106 spinner.WithStyle(c.Styles.Spinner))
107 r := &Repo{
108 common: c,
109 tabs: tb,
110 statusbar: sb,
111 panes: panes,
112 state: loadingState,
113 spinner: s,
114 }
115 return r
116}
117
118// SetSize implements common.Component.
119func (r *Repo) SetSize(width, height int) {
120 r.common.SetSize(width, height)
121 hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
122 r.common.Styles.Repo.Header.GetHeight() +
123 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
124 r.common.Styles.StatusBar.GetHeight()
125 r.tabs.SetSize(width, height-hm)
126 r.statusbar.SetSize(width, height-hm)
127 for _, p := range r.panes {
128 p.SetSize(width, height-hm)
129 }
130}
131
132func (r *Repo) commonHelp() []key.Binding {
133 b := make([]key.Binding, 0)
134 back := r.common.KeyMap.Back
135 back.SetHelp("esc", "back to menu")
136 tab := r.common.KeyMap.Section
137 tab.SetHelp("tab", "switch tab")
138 b = append(b, back)
139 b = append(b, tab)
140 return b
141}
142
143// ShortHelp implements help.KeyMap.
144func (r *Repo) ShortHelp() []key.Binding {
145 b := r.commonHelp()
146 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
147 return b
148}
149
150// FullHelp implements help.KeyMap.
151func (r *Repo) FullHelp() [][]key.Binding {
152 b := make([][]key.Binding, 0)
153 b = append(b, r.commonHelp())
154 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
155 return b
156}
157
158// Init implements tea.View.
159func (r *Repo) Init() tea.Cmd {
160 return tea.Batch(
161 r.tabs.Init(),
162 r.statusbar.Init(),
163 )
164}
165
166// Update implements tea.Model.
167func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168 cmds := make([]tea.Cmd, 0)
169 switch msg := msg.(type) {
170 case RepoMsg:
171 // Set the state to loading when we get a new repository.
172 r.state = loadingState
173 r.panesReady = [lastTab]bool{}
174 r.activeTab = 0
175 r.selectedRepo = msg
176 cmds = append(cmds,
177 r.tabs.Init(),
178 // This will set the selected repo in each pane's model.
179 r.updateModels(msg),
180 r.spinner.Tick,
181 )
182 case RefMsg:
183 r.ref = msg
184 for _, p := range r.panes {
185 // Init will initiate each pane's model with its contents.
186 cmds = append(cmds, p.Init())
187 }
188 cmds = append(cmds,
189 r.updateStatusBarCmd,
190 r.updateModels(msg),
191 )
192 case tabs.SelectTabMsg:
193 r.activeTab = tab(msg)
194 t, cmd := r.tabs.Update(msg)
195 r.tabs = t.(*tabs.Tabs)
196 if cmd != nil {
197 cmds = append(cmds, cmd)
198 }
199 case tabs.ActiveTabMsg:
200 r.activeTab = tab(msg)
201 if r.selectedRepo != nil {
202 cmds = append(cmds,
203 r.updateStatusBarCmd,
204 )
205 }
206 case tea.KeyMsg, tea.MouseMsg:
207 t, cmd := r.tabs.Update(msg)
208 r.tabs = t.(*tabs.Tabs)
209 if cmd != nil {
210 cmds = append(cmds, cmd)
211 }
212 if r.selectedRepo != nil {
213 cmds = append(cmds, r.updateStatusBarCmd)
214 urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
215 cmd := common.CloneCmd(r.common.Config().SSH.PublicURL, r.selectedRepo.Name())
216 if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
217 cmds = append(cmds, copyCmd(cmd, "Command copied to clipboard"))
218 }
219 }
220 switch msg := msg.(type) {
221 case tea.MouseMsg:
222 switch msg.Type {
223 case tea.MouseLeft:
224 switch {
225 case r.common.Zone.Get("repo-help").InBounds(msg):
226 cmds = append(cmds, footer.ToggleFooterCmd)
227 }
228 case tea.MouseRight:
229 switch {
230 case r.common.Zone.Get("repo-main").InBounds(msg):
231 cmds = append(cmds, backCmd)
232 }
233 }
234 }
235 case CopyMsg:
236 txt := msg.Text
237 if cfg := r.common.Config(); cfg != nil {
238 r.common.Output.Copy(txt)
239 }
240 cmds = append(cmds, func() tea.Msg {
241 return statusbar.StatusBarMsg{
242 Value: msg.Message,
243 }
244 })
245 case ReadmeMsg, FileItemsMsg, LogCountMsg, LogItemsMsg, RefItemsMsg:
246 cmds = append(cmds, r.updateRepo(msg))
247 // We have two spinners, one is used to when loading the repository and the
248 // other is used when loading the log.
249 // Check if the spinner ID matches the spinner model.
250 case spinner.TickMsg:
251 switch msg.ID {
252 case r.spinner.ID():
253 if r.state == loadingState {
254 s, cmd := r.spinner.Update(msg)
255 r.spinner = s
256 if cmd != nil {
257 cmds = append(cmds, cmd)
258 }
259 }
260 default:
261 cmds = append(cmds, r.updateRepo(msg))
262 }
263 case UpdateStatusBarMsg:
264 cmds = append(cmds, r.updateStatusBarCmd)
265 case tea.WindowSizeMsg:
266 cmds = append(cmds, r.updateModels(msg))
267 case EmptyRepoMsg:
268 r.ref = nil
269 r.state = readyState
270 cmds = append(cmds,
271 r.updateModels(msg),
272 r.updateStatusBarCmd,
273 )
274 case common.ErrorMsg:
275 r.state = readyState
276 }
277 s, cmd := r.statusbar.Update(msg)
278 r.statusbar = s.(*statusbar.StatusBar)
279 if cmd != nil {
280 cmds = append(cmds, cmd)
281 }
282 m, cmd := r.panes[r.activeTab].Update(msg)
283 r.panes[r.activeTab] = m.(common.Component)
284 if cmd != nil {
285 cmds = append(cmds, cmd)
286 }
287 return r, tea.Batch(cmds...)
288}
289
290// View implements tea.Model.
291func (r *Repo) View() string {
292 s := r.common.Styles.Repo.Base.Copy().
293 Width(r.common.Width).
294 Height(r.common.Height)
295 repoBodyStyle := r.common.Styles.Repo.Body.Copy()
296 hm := repoBodyStyle.GetVerticalFrameSize() +
297 r.common.Styles.Repo.Header.GetHeight() +
298 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
299 r.common.Styles.StatusBar.GetHeight() +
300 r.common.Styles.Tabs.GetHeight() +
301 r.common.Styles.Tabs.GetVerticalFrameSize()
302 mainStyle := repoBodyStyle.
303 Height(r.common.Height - hm)
304 var main string
305 var statusbar string
306 switch r.state {
307 case loadingState:
308 main = fmt.Sprintf("%s loading…", r.spinner.View())
309 case readyState:
310 main = r.panes[r.activeTab].View()
311 statusbar = r.statusbar.View()
312 }
313 main = r.common.Zone.Mark(
314 "repo-main",
315 mainStyle.Render(main),
316 )
317 view := lipgloss.JoinVertical(lipgloss.Top,
318 r.headerView(),
319 r.tabs.View(),
320 main,
321 statusbar,
322 )
323 return s.Render(view)
324}
325
326func (r *Repo) headerView() string {
327 if r.selectedRepo == nil {
328 return ""
329 }
330 truncate := lipgloss.NewStyle().MaxWidth(r.common.Width)
331 name := r.selectedRepo.ProjectName()
332 if name == "" {
333 name = r.selectedRepo.Name()
334 }
335 name = r.common.Styles.Repo.HeaderName.Render(name)
336 desc := r.selectedRepo.Description()
337 if desc == "" {
338 desc = name
339 name = ""
340 } else {
341 desc = r.common.Styles.Repo.HeaderDesc.Render(desc)
342 }
343 urlStyle := r.common.Styles.URLStyle.Copy().
344 Width(r.common.Width - lipgloss.Width(desc) - 1).
345 Align(lipgloss.Right)
346 var url string
347 if cfg := r.common.Config(); cfg != nil {
348 url = common.CloneCmd(cfg.SSH.PublicURL, r.selectedRepo.Name())
349 }
350 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
351 url = r.common.Zone.Mark(
352 fmt.Sprintf("%s-url", r.selectedRepo.Name()),
353 urlStyle.Render(url),
354 )
355 style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
356 return style.Render(
357 lipgloss.JoinVertical(lipgloss.Top,
358 truncate.Render(name),
359 truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
360 desc,
361 url,
362 )),
363 ),
364 )
365}
366
367func (r *Repo) updateStatusBarCmd() tea.Msg {
368 if r.selectedRepo == nil {
369 return nil
370 }
371 value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
372 info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
373 branch := "*"
374 if r.ref != nil {
375 branch += " " + r.ref.Name().Short()
376 }
377 return statusbar.StatusBarMsg{
378 Key: r.selectedRepo.Name(),
379 Value: value,
380 Info: info,
381 Extra: branch,
382 }
383}
384
385func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
386 cmds := make([]tea.Cmd, 0)
387 for i, b := range r.panes {
388 m, cmd := b.Update(msg)
389 r.panes[i] = m.(common.Component)
390 if cmd != nil {
391 cmds = append(cmds, cmd)
392 }
393 }
394 return tea.Batch(cmds...)
395}
396
397func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
398 cmds := make([]tea.Cmd, 0)
399 switch msg := msg.(type) {
400 case LogCountMsg, LogItemsMsg, spinner.TickMsg:
401 switch msg.(type) {
402 case LogItemsMsg:
403 r.panesReady[commitsTab] = true
404 }
405 l, cmd := r.panes[commitsTab].Update(msg)
406 r.panes[commitsTab] = l.(*Log)
407 if cmd != nil {
408 cmds = append(cmds, cmd)
409 }
410 case FileItemsMsg:
411 r.panesReady[filesTab] = true
412 f, cmd := r.panes[filesTab].Update(msg)
413 r.panes[filesTab] = f.(*Files)
414 if cmd != nil {
415 cmds = append(cmds, cmd)
416 }
417 case RefItemsMsg:
418 switch msg.prefix {
419 case git.RefsHeads:
420 r.panesReady[branchesTab] = true
421 b, cmd := r.panes[branchesTab].Update(msg)
422 r.panes[branchesTab] = b.(*Refs)
423 if cmd != nil {
424 cmds = append(cmds, cmd)
425 }
426 case git.RefsTags:
427 r.panesReady[tagsTab] = true
428 t, cmd := r.panes[tagsTab].Update(msg)
429 r.panes[tagsTab] = t.(*Refs)
430 if cmd != nil {
431 cmds = append(cmds, cmd)
432 }
433 }
434 case ReadmeMsg:
435 r.panesReady[readmeTab] = true
436 }
437 if r.isReady() {
438 r.state = readyState
439 }
440 return tea.Batch(cmds...)
441}
442
443func (r *Repo) isReady() bool {
444 ready := true
445 // We purposely ignore the log pane here because it has its own spinner.
446 for _, b := range []bool{
447 r.panesReady[filesTab], r.panesReady[branchesTab],
448 r.panesReady[tagsTab], r.panesReady[readmeTab],
449 } {
450 if !b {
451 ready = false
452 break
453 }
454 }
455 return ready
456}
457
458func copyCmd(text, msg string) tea.Cmd {
459 return func() tea.Msg {
460 return CopyMsg{
461 Text: text,
462 Message: msg,
463 }
464 }
465}
466
467func updateStatusBarCmd() tea.Msg {
468 return UpdateStatusBarMsg{}
469}
470
471func backCmd() tea.Msg {
472 return BackMsg{}
473}