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/log"
13 "github.com/charmbracelet/soft-serve/git"
14 "github.com/charmbracelet/soft-serve/server/backend"
15 "github.com/charmbracelet/soft-serve/ui/common"
16 "github.com/charmbracelet/soft-serve/ui/components/footer"
17 "github.com/charmbracelet/soft-serve/ui/components/statusbar"
18 "github.com/charmbracelet/soft-serve/ui/components/tabs"
19)
20
21var (
22 logger = log.WithPrefix("ui.repo")
23)
24
25type state int
26
27const (
28 loadingState state = iota
29 readyState
30)
31
32type tab int
33
34const (
35 readmeTab tab = iota
36 filesTab
37 commitsTab
38 branchesTab
39 tagsTab
40 lastTab
41)
42
43func (t tab) String() string {
44 return []string{
45 "Readme",
46 "Files",
47 "Commits",
48 "Branches",
49 "Tags",
50 }[t]
51}
52
53// EmptyRepoMsg is a message to indicate that the repository is empty.
54type EmptyRepoMsg struct{}
55
56// CopyURLMsg is a message to copy the URL of the current repository.
57type CopyURLMsg struct{}
58
59// ResetURLMsg is a message to reset the URL string.
60type ResetURLMsg struct{}
61
62// UpdateStatusBarMsg updates the status bar.
63type UpdateStatusBarMsg struct{}
64
65// RepoMsg is a message that contains a git.Repository.
66type RepoMsg backend.Repository
67
68// BackMsg is a message to go back to the previous view.
69type BackMsg struct{}
70
71// Repo is a view for a git repository.
72type Repo struct {
73 common common.Common
74 selectedRepo backend.Repository
75 activeTab tab
76 tabs *tabs.Tabs
77 statusbar *statusbar.StatusBar
78 panes []common.Component
79 ref *git.Reference
80 copyURL time.Time
81 state state
82 spinner spinner.Model
83 panesReady [lastTab]bool
84}
85
86// New returns a new Repo.
87func New(c common.Common) *Repo {
88 sb := statusbar.New(c)
89 ts := make([]string, lastTab)
90 // Tabs must match the order of tab constants above.
91 for i, t := range []tab{readmeTab, filesTab, commitsTab, branchesTab, tagsTab} {
92 ts[i] = t.String()
93 }
94 tb := tabs.New(c, ts)
95 readme := NewReadme(c)
96 log := NewLog(c)
97 files := NewFiles(c)
98 branches := NewRefs(c, git.RefsHeads)
99 tags := NewRefs(c, git.RefsTags)
100 // Make sure the order matches the order of tab constants above.
101 panes := []common.Component{
102 readme,
103 files,
104 log,
105 branches,
106 tags,
107 }
108 s := spinner.New(spinner.WithSpinner(spinner.Dot),
109 spinner.WithStyle(c.Styles.Spinner))
110 r := &Repo{
111 common: c,
112 tabs: tb,
113 statusbar: sb,
114 panes: panes,
115 state: loadingState,
116 spinner: s,
117 }
118 return r
119}
120
121// SetSize implements common.Component.
122func (r *Repo) SetSize(width, height int) {
123 r.common.SetSize(width, height)
124 hm := r.common.Styles.Repo.Body.GetVerticalFrameSize() +
125 r.common.Styles.Repo.Header.GetHeight() +
126 r.common.Styles.Repo.Header.GetVerticalFrameSize() +
127 r.common.Styles.StatusBar.GetHeight()
128 r.tabs.SetSize(width, height-hm)
129 r.statusbar.SetSize(width, height-hm)
130 for _, p := range r.panes {
131 p.SetSize(width, height-hm)
132 }
133}
134
135func (r *Repo) commonHelp() []key.Binding {
136 b := make([]key.Binding, 0)
137 back := r.common.KeyMap.Back
138 back.SetHelp("esc", "back to menu")
139 tab := r.common.KeyMap.Section
140 tab.SetHelp("tab", "switch tab")
141 b = append(b, back)
142 b = append(b, tab)
143 return b
144}
145
146// ShortHelp implements help.KeyMap.
147func (r *Repo) ShortHelp() []key.Binding {
148 b := r.commonHelp()
149 b = append(b, r.panes[r.activeTab].(help.KeyMap).ShortHelp()...)
150 return b
151}
152
153// FullHelp implements help.KeyMap.
154func (r *Repo) FullHelp() [][]key.Binding {
155 b := make([][]key.Binding, 0)
156 b = append(b, r.commonHelp())
157 b = append(b, r.panes[r.activeTab].(help.KeyMap).FullHelp()...)
158 return b
159}
160
161// Init implements tea.View.
162func (r *Repo) Init() tea.Cmd {
163 return tea.Batch(
164 r.tabs.Init(),
165 r.statusbar.Init(),
166 )
167}
168
169// Update implements tea.Model.
170func (r *Repo) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
171 cmds := make([]tea.Cmd, 0)
172 switch msg := msg.(type) {
173 case RepoMsg:
174 // Set the state to loading when we get a new repository.
175 r.state = loadingState
176 r.panesReady = [lastTab]bool{}
177 r.activeTab = 0
178 r.selectedRepo = msg
179 cmds = append(cmds,
180 r.tabs.Init(),
181 // This will set the selected repo in each pane's model.
182 r.updateModels(msg),
183 r.spinner.Tick,
184 )
185 case RefMsg:
186 r.ref = msg
187 for _, p := range r.panes {
188 // Init will initiate each pane's model with its contents.
189 cmds = append(cmds, p.Init())
190 }
191 cmds = append(cmds,
192 r.updateStatusBarCmd,
193 r.updateModels(msg),
194 )
195 case tabs.SelectTabMsg:
196 r.activeTab = tab(msg)
197 t, cmd := r.tabs.Update(msg)
198 r.tabs = t.(*tabs.Tabs)
199 if cmd != nil {
200 cmds = append(cmds, cmd)
201 }
202 case tabs.ActiveTabMsg:
203 r.activeTab = tab(msg)
204 if r.selectedRepo != nil {
205 cmds = append(cmds,
206 r.updateStatusBarCmd,
207 )
208 }
209 case tea.KeyMsg, tea.MouseMsg:
210 t, cmd := r.tabs.Update(msg)
211 r.tabs = t.(*tabs.Tabs)
212 if cmd != nil {
213 cmds = append(cmds, cmd)
214 }
215 if r.selectedRepo != nil {
216 cmds = append(cmds, r.updateStatusBarCmd)
217 urlID := fmt.Sprintf("%s-url", r.selectedRepo.Name())
218 if msg, ok := msg.(tea.MouseMsg); ok && r.common.Zone.Get(urlID).InBounds(msg) {
219 cmds = append(cmds, r.copyURLCmd())
220 }
221 }
222 switch msg := msg.(type) {
223 case tea.MouseMsg:
224 switch msg.Type {
225 case tea.MouseLeft:
226 switch {
227 case r.common.Zone.Get("repo-help").InBounds(msg):
228 cmds = append(cmds, footer.ToggleFooterCmd)
229 }
230 case tea.MouseRight:
231 switch {
232 case r.common.Zone.Get("repo-main").InBounds(msg):
233 cmds = append(cmds, backCmd)
234 }
235 }
236 }
237 case CopyURLMsg:
238 if cfg := r.common.Config(); cfg != nil {
239 r.common.Copy.Copy(
240 common.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name()),
241 )
242 }
243 case ResetURLMsg:
244 r.copyURL = time.Time{}
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.RepoURL(cfg.SSH.PublicURL, r.selectedRepo.Name())
349 }
350 if !r.copyURL.IsZero() && r.copyURL.Add(time.Second).After(time.Now()) {
351 url = "copied!"
352 }
353 url = common.TruncateString(url, r.common.Width-lipgloss.Width(desc)-1)
354 url = r.common.Zone.Mark(
355 fmt.Sprintf("%s-url", r.selectedRepo.Name()),
356 urlStyle.Render(url),
357 )
358 style := r.common.Styles.Repo.Header.Copy().Width(r.common.Width)
359 return style.Render(
360 lipgloss.JoinVertical(lipgloss.Top,
361 truncate.Render(name),
362 truncate.Render(lipgloss.JoinHorizontal(lipgloss.Left,
363 desc,
364 url,
365 )),
366 ),
367 )
368}
369
370func (r *Repo) updateStatusBarCmd() tea.Msg {
371 if r.selectedRepo == nil {
372 return nil
373 }
374 value := r.panes[r.activeTab].(statusbar.Model).StatusBarValue()
375 info := r.panes[r.activeTab].(statusbar.Model).StatusBarInfo()
376 branch := "*"
377 if r.ref != nil {
378 branch += " " + r.ref.Name().Short()
379 }
380 return statusbar.StatusBarMsg{
381 Key: r.selectedRepo.Name(),
382 Value: value,
383 Info: info,
384 Branch: branch,
385 }
386}
387
388func (r *Repo) updateModels(msg tea.Msg) tea.Cmd {
389 cmds := make([]tea.Cmd, 0)
390 for i, b := range r.panes {
391 m, cmd := b.Update(msg)
392 r.panes[i] = m.(common.Component)
393 if cmd != nil {
394 cmds = append(cmds, cmd)
395 }
396 }
397 return tea.Batch(cmds...)
398}
399
400func (r *Repo) updateRepo(msg tea.Msg) tea.Cmd {
401 cmds := make([]tea.Cmd, 0)
402 switch msg := msg.(type) {
403 case LogCountMsg, LogItemsMsg, spinner.TickMsg:
404 switch msg.(type) {
405 case LogItemsMsg:
406 r.panesReady[commitsTab] = true
407 }
408 l, cmd := r.panes[commitsTab].Update(msg)
409 r.panes[commitsTab] = l.(*Log)
410 if cmd != nil {
411 cmds = append(cmds, cmd)
412 }
413 case FileItemsMsg:
414 r.panesReady[filesTab] = true
415 f, cmd := r.panes[filesTab].Update(msg)
416 r.panes[filesTab] = f.(*Files)
417 if cmd != nil {
418 cmds = append(cmds, cmd)
419 }
420 case RefItemsMsg:
421 switch msg.prefix {
422 case git.RefsHeads:
423 r.panesReady[branchesTab] = true
424 b, cmd := r.panes[branchesTab].Update(msg)
425 r.panes[branchesTab] = b.(*Refs)
426 if cmd != nil {
427 cmds = append(cmds, cmd)
428 }
429 case git.RefsTags:
430 r.panesReady[tagsTab] = true
431 t, cmd := r.panes[tagsTab].Update(msg)
432 r.panes[tagsTab] = t.(*Refs)
433 if cmd != nil {
434 cmds = append(cmds, cmd)
435 }
436 }
437 case ReadmeMsg:
438 r.panesReady[readmeTab] = true
439 }
440 if r.isReady() {
441 r.state = readyState
442 }
443 return tea.Batch(cmds...)
444}
445
446func (r *Repo) isReady() bool {
447 ready := true
448 // We purposely ignore the log pane here because it has its own spinner.
449 for _, b := range []bool{
450 r.panesReady[filesTab], r.panesReady[branchesTab],
451 r.panesReady[tagsTab], r.panesReady[readmeTab],
452 } {
453 if !b {
454 ready = false
455 break
456 }
457 }
458 return ready
459}
460
461func (r *Repo) copyURLCmd() tea.Cmd {
462 r.copyURL = time.Now()
463 return tea.Batch(
464 func() tea.Msg {
465 return CopyURLMsg{}
466 },
467 tea.Tick(time.Second, func(time.Time) tea.Msg {
468 return ResetURLMsg{}
469 }),
470 )
471}
472
473func updateStatusBarCmd() tea.Msg {
474 return UpdateStatusBarMsg{}
475}
476
477func backCmd() tea.Msg {
478 return BackMsg{}
479}