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