repo.go

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